on main help sign in
add support for images (#144)
9fec8dfphantomreactor
go.mod+3−7
@@ -2,8 +2,6 @@ module github.com/opencode-ai/opencode
2
2
3go 1.24.0
3go 1.24.0
4
4
5−toolchain go1.24.2
6−
7require (
5require (
8 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
9 github.com/JohannesKaufmann/html-to-markdown v1.6.0
7 github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -16,7 +14,6 @@ require (
16 github.com/charmbracelet/bubbles v0.20.0
14 github.com/charmbracelet/bubbles v0.20.0
17 github.com/charmbracelet/bubbletea v1.3.4
15 github.com/charmbracelet/bubbletea v1.3.4
18 github.com/charmbracelet/glamour v0.9.1
16 github.com/charmbracelet/glamour v0.9.1
19− github.com/charmbracelet/huh v0.6.0
20 github.com/charmbracelet/lipgloss v1.1.0
17 github.com/charmbracelet/lipgloss v1.1.0
21 github.com/charmbracelet/x/ansi v0.8.0
18 github.com/charmbracelet/x/ansi v0.8.0
22 github.com/fsnotify/fsnotify v1.8.0
19 github.com/fsnotify/fsnotify v1.8.0
@@ -68,11 +65,10 @@ require (
68 github.com/aymerick/douceur v0.2.0 // indirect
65 github.com/aymerick/douceur v0.2.0 // indirect
69 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
66 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
70 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
67 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
71− github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
72 github.com/charmbracelet/x/term v0.2.1 // indirect
68 github.com/charmbracelet/x/term v0.2.1 // indirect
73 github.com/davecgh/go-spew v1.1.1 // indirect
69 github.com/davecgh/go-spew v1.1.1 // indirect
70+ github.com/disintegration/imaging v1.6.2
74 github.com/dlclark/regexp2 v1.11.4 // indirect
71 github.com/dlclark/regexp2 v1.11.4 // indirect
75− github.com/dustin/go-humanize v1.0.1 // indirect
76 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
72 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
77 github.com/felixge/httpsnoop v1.0.4 // indirect
73 github.com/felixge/httpsnoop v1.0.4 // indirect
78 github.com/go-logr/logr v1.4.2 // indirect
74 github.com/go-logr/logr v1.4.2 // indirect
@@ -85,13 +81,12 @@ require (
85 github.com/gorilla/css v1.0.1 // indirect
81 github.com/gorilla/css v1.0.1 // indirect
86 github.com/inconshreveable/mousetrap v1.1.0 // indirect
82 github.com/inconshreveable/mousetrap v1.1.0 // indirect
87 github.com/kylelemons/godebug v1.1.0 // indirect
83 github.com/kylelemons/godebug v1.1.0 // indirect
88− github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
84+ github.com/lucasb-eyer/go-colorful v1.2.0
89 github.com/mattn/go-isatty v0.0.20 // indirect
85 github.com/mattn/go-isatty v0.0.20 // indirect
90 github.com/mattn/go-localereader v0.0.1 // indirect
86 github.com/mattn/go-localereader v0.0.1 // indirect
91 github.com/mattn/go-runewidth v0.0.16 // indirect
87 github.com/mattn/go-runewidth v0.0.16 // indirect
92 github.com/mfridman/interpolate v0.0.2 // indirect
88 github.com/mfridman/interpolate v0.0.2 // indirect
93 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
89 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
94− github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
95 github.com/muesli/cancelreader v0.2.2 // indirect
90 github.com/muesli/cancelreader v0.2.2 // indirect
96 github.com/ncruces/julianday v1.0.0 // indirect
91 github.com/ncruces/julianday v1.0.0 // indirect
97 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
92 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
@@ -123,6 +118,7 @@ require (
123 go.opentelemetry.io/otel/trace v1.35.0 // indirect
118 go.opentelemetry.io/otel/trace v1.35.0 // indirect
124 go.uber.org/multierr v1.11.0 // indirect
119 go.uber.org/multierr v1.11.0 // indirect
125 golang.org/x/crypto v0.37.0 // indirect
120 golang.org/x/crypto v0.37.0 // indirect
121+ golang.org/x/image v0.26.0 // indirect
126 golang.org/x/net v0.39.0 // indirect
122 golang.org/x/net v0.39.0 // indirect
127 golang.org/x/oauth2 v0.25.0 // indirect
123 golang.org/x/oauth2 v0.25.0 // indirect
128 golang.org/x/sync v0.13.0 // indirect
124 golang.org/x/sync v0.13.0 // indirect
go.sum+5−6
@@ -82,8 +82,6 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
82github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
82github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
83github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
83github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
84github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
84github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
85−github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
86−github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
87github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
85github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
88github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
86github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
89github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
87github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
@@ -92,14 +90,14 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
92github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
90github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
93github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
91github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
94github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
92github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
95−github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
96−github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
97github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
93github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
98github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
94github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
99github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
95github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
100github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
96github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
101github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
97github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
102github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
98github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
99+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
100+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
103github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
101github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
104github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
102github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
105github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
103github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -169,8 +167,6 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
169github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
167github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
170github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
168github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
171github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
169github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
172−github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
173−github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
174github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
170github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
175github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
171github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
176github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
172github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -283,6 +279,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
283golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
279golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
284golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
280golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
285golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
281golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
282+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
283+golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
284+golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
286golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
285golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
287golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
286golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
288golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
287golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
internal/llm/agent/agent.go+25−20
@@ -38,7 +38,7 @@ func (e *AgentEvent) Response() message.Message {
38}
38}
39
39
40type Service interface {
40type Service interface {
41− Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
41+ Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
42 Cancel(sessionID string)
42 Cancel(sessionID string)
43 IsSessionBusy(sessionID string) bool
43 IsSessionBusy(sessionID string) bool
44 IsBusy() bool
44 IsBusy() bool
@@ -117,6 +117,9 @@ func (a *agent) IsSessionBusy(sessionID string) bool {
117}
117}
118
118
119func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
119func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
120+ if content == "" {
121+ return nil
122+ }
120 if a.titleProvider == nil {
123 if a.titleProvider == nil {
121 return nil
124 return nil
122 }
125 }
@@ -124,16 +127,13 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
124 if err != nil {
127 if err != nil {
125 return err
128 return err
126 }
129 }
130+ parts := []message.ContentPart{message.TextContent{Text: content}}
127 response, err := a.titleProvider.SendMessages(
131 response, err := a.titleProvider.SendMessages(
128 ctx,
132 ctx,
129 []message.Message{
133 []message.Message{
130 {
134 {
131− Role: message.User,
135+ Role: message.User,
132− Parts: []message.ContentPart{
136+ Parts: parts,
133− message.TextContent{
134− Text: content,
135− },
136− },
137 },
137 },
138 },
138 },
139 make([]tools.BaseTool, 0),
139 make([]tools.BaseTool, 0),
@@ -158,7 +158,10 @@ func (a *agent) err(err error) AgentEvent {
158 }
158 }
159}
159}
160
160
161−func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
161+func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
162+ if !a.provider.Model().SupportsAttachments && attachments != nil {
163+ attachments = nil
164+ }
162 events := make(chan AgentEvent)
165 events := make(chan AgentEvent)
163 if a.IsSessionBusy(sessionID) {
166 if a.IsSessionBusy(sessionID) {
164 return nil, ErrSessionBusy
167 return nil, ErrSessionBusy
@@ -172,10 +175,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch
172 defer logging.RecoverPanic("agent.Run", func() {
175 defer logging.RecoverPanic("agent.Run", func() {
173 events <- a.err(fmt.Errorf("panic while running the agent"))
176 events <- a.err(fmt.Errorf("panic while running the agent"))
174 })
177 })
175−
178+ var attachmentParts []message.ContentPart
179+ for _, attachment := range attachments {
180+ attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
181+ }
176− result := a.processGeneration(genCtx, sessionID, content)
182+ result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
177 if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
183 if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
178− logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result))
184+ logging.ErrorPersist(result.Err().Error())
179 }
185 }
180 logging.Debug("Request completed", "sessionID", sessionID)
186 logging.Debug("Request completed", "sessionID", sessionID)
181 a.activeRequests.Delete(sessionID)
187 a.activeRequests.Delete(sessionID)
@@ -186,7 +192,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch
186 return events, nil
192 return events, nil
187}
193}
188
194
189−func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
195+func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
190 // List existing messages; if none, start title generation asynchronously.
196 // List existing messages; if none, start title generation asynchronously.
191 msgs, err := a.messages.List(ctx, sessionID)
197 msgs, err := a.messages.List(ctx, sessionID)
192 if err != nil {
198 if err != nil {
@@ -204,13 +210,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
204 }()
210 }()
205 }
211 }
206
212
207− userMsg, err := a.createUserMessage(ctx, sessionID, content)
213+ userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
208 if err != nil {
214 if err != nil {
209 return a.err(fmt.Errorf("failed to create user message: %w", err))
215 return a.err(fmt.Errorf("failed to create user message: %w", err))
210 }
216 }
211−
212 // Append the new user message to the conversation history.
217 // Append the new user message to the conversation history.
213 msgHistory := append(msgs, userMsg)
218 msgHistory := append(msgs, userMsg)
219+
214 for {
220 for {
215 // Check for cancellation before each iteration
221 // Check for cancellation before each iteration
216 select {
222 select {
@@ -240,12 +246,12 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
240 }
246 }
241}
247}
242
248
243−func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
249+func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
250+ parts := []message.ContentPart{message.TextContent{Text: content}}
251+ parts = append(parts, attachmentParts...)
244 return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
252 return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
245− Role: message.User,
253+ Role: message.User,
246− Parts: []message.ContentPart{
254+ Parts: parts,
247− message.TextContent{Text: content},
248− },
249 })
255 })
250}
256}
251
257
@@ -310,7 +316,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
310 }
316 }
311 continue
317 continue
312 }
318 }
313−
314 toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
319 toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
315 ID: toolCall.ID,
320 ID: toolCall.ID,
316 Name: toolCall.Name,
321 Name: toolCall.Name,
internal/llm/models/anthropic.go+56−51
@@ -14,64 +14,69 @@ const (
14// https://docs.anthropic.com/en/docs/about-claude/models/all-models
14// https://docs.anthropic.com/en/docs/about-claude/models/all-models
15var AnthropicModels = map[ModelID]Model{
15var AnthropicModels = map[ModelID]Model{
16 Claude35Sonnet: {
16 Claude35Sonnet: {
17− ID: Claude35Sonnet,
17+ ID: Claude35Sonnet,
18− Name: "Claude 3.5 Sonnet",
18+ Name: "Claude 3.5 Sonnet",
19− Provider: ProviderAnthropic,
19+ Provider: ProviderAnthropic,
20− APIModel: "claude-3-5-sonnet-latest",
20+ APIModel: "claude-3-5-sonnet-latest",
21− CostPer1MIn: 3.0,
21+ CostPer1MIn: 3.0,
22− CostPer1MInCached: 3.75,
22+ CostPer1MInCached: 3.75,
23− CostPer1MOutCached: 0.30,
23+ CostPer1MOutCached: 0.30,
24− CostPer1MOut: 15.0,
24+ CostPer1MOut: 15.0,
25− ContextWindow: 200000,
25+ ContextWindow: 200000,
26− DefaultMaxTokens: 5000,
26+ DefaultMaxTokens: 5000,
27+ SupportsAttachments: true,
27 },
28 },
28 Claude3Haiku: {
29 Claude3Haiku: {
29− ID: Claude3Haiku,
30+ ID: Claude3Haiku,
30− Name: "Claude 3 Haiku",
31+ Name: "Claude 3 Haiku",
31− Provider: ProviderAnthropic,
32+ Provider: ProviderAnthropic,
32− APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
33+ APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
33− CostPer1MIn: 0.25,
34+ CostPer1MIn: 0.25,
34− CostPer1MInCached: 0.30,
35+ CostPer1MInCached: 0.30,
35− CostPer1MOutCached: 0.03,
36+ CostPer1MOutCached: 0.03,
36− CostPer1MOut: 1.25,
37+ CostPer1MOut: 1.25,
37− ContextWindow: 200000,
38+ ContextWindow: 200000,
38− DefaultMaxTokens: 4096,
39+ DefaultMaxTokens: 4096,
40+ SupportsAttachments: true,
39 },
41 },
40 Claude37Sonnet: {
42 Claude37Sonnet: {
41− ID: Claude37Sonnet,
43+ ID: Claude37Sonnet,
42− Name: "Claude 3.7 Sonnet",
44+ Name: "Claude 3.7 Sonnet",
43− Provider: ProviderAnthropic,
45+ Provider: ProviderAnthropic,
44− APIModel: "claude-3-7-sonnet-latest",
46+ APIModel: "claude-3-7-sonnet-latest",
45− CostPer1MIn: 3.0,
47+ CostPer1MIn: 3.0,
46− CostPer1MInCached: 3.75,
48+ CostPer1MInCached: 3.75,
47− CostPer1MOutCached: 0.30,
49+ CostPer1MOutCached: 0.30,
48− CostPer1MOut: 15.0,
50+ CostPer1MOut: 15.0,
49− ContextWindow: 200000,
51+ ContextWindow: 200000,
50− DefaultMaxTokens: 50000,
52+ DefaultMaxTokens: 50000,
51− CanReason: true,
53+ CanReason: true,
54+ SupportsAttachments: true,
52 },
55 },
53 Claude35Haiku: {
56 Claude35Haiku: {
54− ID: Claude35Haiku,
57+ ID: Claude35Haiku,
55− Name: "Claude 3.5 Haiku",
58+ Name: "Claude 3.5 Haiku",
56− Provider: ProviderAnthropic,
59+ Provider: ProviderAnthropic,
57− APIModel: "claude-3-5-haiku-latest",
60+ APIModel: "claude-3-5-haiku-latest",
58− CostPer1MIn: 0.80,
61+ CostPer1MIn: 0.80,
59− CostPer1MInCached: 1.0,
62+ CostPer1MInCached: 1.0,
60− CostPer1MOutCached: 0.08,
63+ CostPer1MOutCached: 0.08,
61− CostPer1MOut: 4.0,
64+ CostPer1MOut: 4.0,
62− ContextWindow: 200000,
65+ ContextWindow: 200000,
63− DefaultMaxTokens: 4096,
66+ DefaultMaxTokens: 4096,
67+ SupportsAttachments: true,
64 },
68 },
65 Claude3Opus: {
69 Claude3Opus: {
66− ID: Claude3Opus,
70+ ID: Claude3Opus,
67− Name: "Claude 3 Opus",
71+ Name: "Claude 3 Opus",
68− Provider: ProviderAnthropic,
72+ Provider: ProviderAnthropic,
69− APIModel: "claude-3-opus-latest",
73+ APIModel: "claude-3-opus-latest",
70− CostPer1MIn: 15.0,
74+ CostPer1MIn: 15.0,
71− CostPer1MInCached: 18.75,
75+ CostPer1MInCached: 18.75,
72− CostPer1MOutCached: 1.50,
76+ CostPer1MOutCached: 1.50,
73− CostPer1MOut: 75.0,
77+ CostPer1MOut: 75.0,
74− ContextWindow: 200000,
78+ ContextWindow: 200000,
75− DefaultMaxTokens: 4096,
79+ DefaultMaxTokens: 4096,
80+ SupportsAttachments: true,
76 },
81 },
77}
82}
internal/llm/models/azure.go+126−115
@@ -18,140 +18,151 @@ const (
18
18
19var AzureModels = map[ModelID]Model{
19var AzureModels = map[ModelID]Model{
20 AzureGPT41: {
20 AzureGPT41: {
21− ID: AzureGPT41,
21+ ID: AzureGPT41,
22− Name: "Azure OpenAI – GPT 4.1",
22+ Name: "Azure OpenAI – GPT 4.1",
23− Provider: ProviderAzure,
23+ Provider: ProviderAzure,
24− APIModel: "gpt-4.1",
24+ APIModel: "gpt-4.1",
25+ CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
26+ CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
25− CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
27+ CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
26− CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
28+ CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
27− CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
29+ ContextWindow: OpenAIModels[GPT41].ContextWindow,
28− CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
30+ DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
29− ContextWindow: OpenAIModels[GPT41].ContextWindow,
31+ SupportsAttachments: true,
30− DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
31 },
32 },
32 AzureGPT41Mini: {
33 AzureGPT41Mini: {
33− ID: AzureGPT41Mini,
34+ ID: AzureGPT41Mini,
34− Name: "Azure OpenAI – GPT 4.1 mini",
35+ Name: "Azure OpenAI – GPT 4.1 mini",
35− Provider: ProviderAzure,
36+ Provider: ProviderAzure,
36− APIModel: "gpt-4.1-mini",
37+ APIModel: "gpt-4.1-mini",
38+ CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
39+ CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
37− CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
40+ CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
38− CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
41+ CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
39− CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
42+ ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
40− CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
41− ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
43+ DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
42− DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
44+ SupportsAttachments: true,
43 },
45 },
44 AzureGPT41Nano: {
46 AzureGPT41Nano: {
45− ID: AzureGPT41Nano,
47+ ID: AzureGPT41Nano,
46− Name: "Azure OpenAI – GPT 4.1 nano",
48+ Name: "Azure OpenAI – GPT 4.1 nano",
47− Provider: ProviderAzure,
49+ Provider: ProviderAzure,
48− APIModel: "gpt-4.1-nano",
50+ APIModel: "gpt-4.1-nano",
51+ CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
52+ CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
49− CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
53+ CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
50− CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
54+ CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
51− CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
55+ ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
52− CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
53− ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
56+ DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
54− DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
57+ SupportsAttachments: true,
55 },
58 },
56 AzureGPT45Preview: {
59 AzureGPT45Preview: {
57− ID: AzureGPT45Preview,
60+ ID: AzureGPT45Preview,
58− Name: "Azure OpenAI – GPT 4.5 preview",
61+ Name: "Azure OpenAI – GPT 4.5 preview",
59− Provider: ProviderAzure,
62+ Provider: ProviderAzure,
60− APIModel: "gpt-4.5-preview",
63+ APIModel: "gpt-4.5-preview",
64+ CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
65+ CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
61− CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
66+ CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
62− CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
67+ CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
63− CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
68+ ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
64− CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
65− ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
69+ DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
66− DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
70+ SupportsAttachments: true,
67 },
71 },
68 AzureGPT4o: {
72 AzureGPT4o: {
69− ID: AzureGPT4o,
73+ ID: AzureGPT4o,
70− Name: "Azure OpenAI – GPT-4o",
74+ Name: "Azure OpenAI – GPT-4o",
71− Provider: ProviderAzure,
75+ Provider: ProviderAzure,
72− APIModel: "gpt-4o",
76+ APIModel: "gpt-4o",
77+ CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
78+ CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
73− CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
79+ CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
74− CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
80+ CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
75− CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
81+ ContextWindow: OpenAIModels[GPT4o].ContextWindow,
76− CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
82+ DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
77− ContextWindow: OpenAIModels[GPT4o].ContextWindow,
83+ SupportsAttachments: true,
78− DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
79 },
84 },
80 AzureGPT4oMini: {
85 AzureGPT4oMini: {
81− ID: AzureGPT4oMini,
86+ ID: AzureGPT4oMini,
82− Name: "Azure OpenAI – GPT-4o mini",
87+ Name: "Azure OpenAI – GPT-4o mini",
83− Provider: ProviderAzure,
88+ Provider: ProviderAzure,
84− APIModel: "gpt-4o-mini",
89+ APIModel: "gpt-4o-mini",
90+ CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
91+ CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
85− CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
92+ CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
86− CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
93+ CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
87− CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
94+ ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
88− CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
89− ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
95+ DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
90− DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
96+ SupportsAttachments: true,
91 },
97 },
92 AzureO1: {
98 AzureO1: {
93− ID: AzureO1,
99+ ID: AzureO1,
94− Name: "Azure OpenAI – O1",
100+ Name: "Azure OpenAI – O1",
95− Provider: ProviderAzure,
101+ Provider: ProviderAzure,
96− APIModel: "o1",
102+ APIModel: "o1",
97− CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
103+ CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
98− CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
104+ CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
99− CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
105+ CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
100− CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
106+ CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
101− ContextWindow: OpenAIModels[O1].ContextWindow,
107+ ContextWindow: OpenAIModels[O1].ContextWindow,
102− DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
108+ DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
103− CanReason: OpenAIModels[O1].CanReason,
109+ CanReason: OpenAIModels[O1].CanReason,
110+ SupportsAttachments: true,
104 },
111 },
105 AzureO1Mini: {
112 AzureO1Mini: {
106− ID: AzureO1Mini,
113+ ID: AzureO1Mini,
107− Name: "Azure OpenAI – O1 mini",
114+ Name: "Azure OpenAI – O1 mini",
108− Provider: ProviderAzure,
115+ Provider: ProviderAzure,
109− APIModel: "o1-mini",
116+ APIModel: "o1-mini",
117+ CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
118+ CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
110− CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
119+ CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
111− CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
120+ CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
112− CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
121+ ContextWindow: OpenAIModels[O1Mini].ContextWindow,
113− CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
114− ContextWindow: OpenAIModels[O1Mini].ContextWindow,
115− DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
122+ DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
116− CanReason: OpenAIModels[O1Mini].CanReason,
123+ CanReason: OpenAIModels[O1Mini].CanReason,
124+ SupportsAttachments: true,
117 },
125 },
118 AzureO3: {
126 AzureO3: {
119− ID: AzureO3,
127+ ID: AzureO3,
120− Name: "Azure OpenAI – O3",
128+ Name: "Azure OpenAI – O3",
121− Provider: ProviderAzure,
129+ Provider: ProviderAzure,
122− APIModel: "o3",
130+ APIModel: "o3",
123− CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
131+ CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
124− CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
132+ CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
125− CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
133+ CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
126− CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
134+ CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
127− ContextWindow: OpenAIModels[O3].ContextWindow,
135+ ContextWindow: OpenAIModels[O3].ContextWindow,
128− DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
136+ DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
129− CanReason: OpenAIModels[O3].CanReason,
137+ CanReason: OpenAIModels[O3].CanReason,
138+ SupportsAttachments: true,
130 },
139 },
131 AzureO3Mini: {
140 AzureO3Mini: {
132− ID: AzureO3Mini,
141+ ID: AzureO3Mini,
133− Name: "Azure OpenAI – O3 mini",
142+ Name: "Azure OpenAI – O3 mini",
134− Provider: ProviderAzure,
143+ Provider: ProviderAzure,
135− APIModel: "o3-mini",
144+ APIModel: "o3-mini",
145+ CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
146+ CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
136− CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
147+ CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
137− CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
148+ CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
138− CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
149+ ContextWindow: OpenAIModels[O3Mini].ContextWindow,
139− CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
140− ContextWindow: OpenAIModels[O3Mini].ContextWindow,
141− DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
150+ DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
142− CanReason: OpenAIModels[O3Mini].CanReason,
151+ CanReason: OpenAIModels[O3Mini].CanReason,
152+ SupportsAttachments: false,
143 },
153 },
144 AzureO4Mini: {
154 AzureO4Mini: {
145− ID: AzureO4Mini,
155+ ID: AzureO4Mini,
146− Name: "Azure OpenAI – O4 mini",
156+ Name: "Azure OpenAI – O4 mini",
147− Provider: ProviderAzure,
157+ Provider: ProviderAzure,
148− APIModel: "o4-mini",
158+ APIModel: "o4-mini",
159+ CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
160+ CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
149− CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
161+ CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
150− CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
162+ CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
151− CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
163+ ContextWindow: OpenAIModels[O4Mini].ContextWindow,
152− CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
153− ContextWindow: OpenAIModels[O4Mini].ContextWindow,
154− DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
164+ DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
155− CanReason: OpenAIModels[O4Mini].CanReason,
165+ CanReason: OpenAIModels[O4Mini].CanReason,
166+ SupportsAttachments: true,
156 },
167 },
157}
168}
internal/llm/models/gemini.go+44−40
@@ -12,52 +12,56 @@ const (
12
12
13var GeminiModels = map[ModelID]Model{
13var GeminiModels = map[ModelID]Model{
14 Gemini25Flash: {
14 Gemini25Flash: {
15− ID: Gemini25Flash,
15+ ID: Gemini25Flash,
16− Name: "Gemini 2.5 Flash",
16+ Name: "Gemini 2.5 Flash",
17− Provider: ProviderGemini,
17+ Provider: ProviderGemini,
18− APIModel: "gemini-2.5-flash-preview-04-17",
18+ APIModel: "gemini-2.5-flash-preview-04-17",
19− CostPer1MIn: 0.15,
19+ CostPer1MIn: 0.15,
20− CostPer1MInCached: 0,
20+ CostPer1MInCached: 0,
21− CostPer1MOutCached: 0,
21+ CostPer1MOutCached: 0,
22− CostPer1MOut: 0.60,
22+ CostPer1MOut: 0.60,
23− ContextWindow: 1000000,
23+ ContextWindow: 1000000,
24− DefaultMaxTokens: 50000,
24+ DefaultMaxTokens: 50000,
25+ SupportsAttachments: true,
25 },
26 },
26 Gemini25: {
27 Gemini25: {
27− ID: Gemini25,
28+ ID: Gemini25,
28− Name: "Gemini 2.5 Pro",
29+ Name: "Gemini 2.5 Pro",
29− Provider: ProviderGemini,
30+ Provider: ProviderGemini,
30− APIModel: "gemini-2.5-pro-preview-03-25",
31+ APIModel: "gemini-2.5-pro-preview-03-25",
31− CostPer1MIn: 1.25,
32+ CostPer1MIn: 1.25,
32− CostPer1MInCached: 0,
33+ CostPer1MInCached: 0,
33− CostPer1MOutCached: 0,
34+ CostPer1MOutCached: 0,
34− CostPer1MOut: 10,
35+ CostPer1MOut: 10,
35− ContextWindow: 1000000,
36+ ContextWindow: 1000000,
36− DefaultMaxTokens: 50000,
37+ DefaultMaxTokens: 50000,
38+ SupportsAttachments: true,
37 },
39 },
38
40
39 Gemini20Flash: {
41 Gemini20Flash: {
40− ID: Gemini20Flash,
42+ ID: Gemini20Flash,
41− Name: "Gemini 2.0 Flash",
43+ Name: "Gemini 2.0 Flash",
42− Provider: ProviderGemini,
44+ Provider: ProviderGemini,
43− APIModel: "gemini-2.0-flash",
45+ APIModel: "gemini-2.0-flash",
44− CostPer1MIn: 0.10,
46+ CostPer1MIn: 0.10,
45− CostPer1MInCached: 0,
47+ CostPer1MInCached: 0,
46− CostPer1MOutCached: 0,
48+ CostPer1MOutCached: 0,
47− CostPer1MOut: 0.40,
49+ CostPer1MOut: 0.40,
48− ContextWindow: 1000000,
50+ ContextWindow: 1000000,
49− DefaultMaxTokens: 6000,
51+ DefaultMaxTokens: 6000,
52+ SupportsAttachments: true,
50 },
53 },
51 Gemini20FlashLite: {
54 Gemini20FlashLite: {
52− ID: Gemini20FlashLite,
55+ ID: Gemini20FlashLite,
53− Name: "Gemini 2.0 Flash Lite",
56+ Name: "Gemini 2.0 Flash Lite",
54− Provider: ProviderGemini,
57+ Provider: ProviderGemini,
55− APIModel: "gemini-2.0-flash-lite",
58+ APIModel: "gemini-2.0-flash-lite",
56− CostPer1MIn: 0.05,
59+ CostPer1MIn: 0.05,
57− CostPer1MInCached: 0,
60+ CostPer1MInCached: 0,
58− CostPer1MOutCached: 0,
61+ CostPer1MOutCached: 0,
59− CostPer1MOut: 0.30,
62+ CostPer1MOut: 0.30,
60− ContextWindow: 1000000,
63+ ContextWindow: 1000000,
61− DefaultMaxTokens: 6000,
64+ DefaultMaxTokens: 6000,
65+ SupportsAttachments: true,
62 },
66 },
63}
67}
internal/llm/models/groq.go+43−38
@@ -28,55 +28,60 @@ var GroqModels = map[ModelID]Model{
28 ContextWindow: 128_000,
28 ContextWindow: 128_000,
29 DefaultMaxTokens: 50000,
29 DefaultMaxTokens: 50000,
30 // for some reason, the groq api doesn't like the reasoningEffort parameter
30 // for some reason, the groq api doesn't like the reasoningEffort parameter
31− CanReason: false,
31+ CanReason: false,
32+ SupportsAttachments: false,
32 },
33 },
33
34
34 Llama4Scout: {
35 Llama4Scout: {
35− ID: Llama4Scout,
36+ ID: Llama4Scout,
36− Name: "Llama4Scout",
37+ Name: "Llama4Scout",
37− Provider: ProviderGROQ,
38+ Provider: ProviderGROQ,
38− APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
39+ APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
39− CostPer1MIn: 0.11,
40+ CostPer1MIn: 0.11,
40− CostPer1MInCached: 0,
41+ CostPer1MInCached: 0,
41− CostPer1MOutCached: 0,
42+ CostPer1MOutCached: 0,
42− CostPer1MOut: 0.34,
43+ CostPer1MOut: 0.34,
43− ContextWindow: 128_000, // 10M when?
44+ ContextWindow: 128_000, // 10M when?
45+ SupportsAttachments: true,
44 },
46 },
45
47
46 Llama4Maverick: {
48 Llama4Maverick: {
47− ID: Llama4Maverick,
49+ ID: Llama4Maverick,
48− Name: "Llama4Maverick",
50+ Name: "Llama4Maverick",
49− Provider: ProviderGROQ,
51+ Provider: ProviderGROQ,
50− APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
52+ APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
51− CostPer1MIn: 0.20,
53+ CostPer1MIn: 0.20,
52− CostPer1MInCached: 0,
54+ CostPer1MInCached: 0,
53− CostPer1MOutCached: 0,
55+ CostPer1MOutCached: 0,
54− CostPer1MOut: 0.20,
56+ CostPer1MOut: 0.20,
55− ContextWindow: 128_000,
57+ ContextWindow: 128_000,
58+ SupportsAttachments: true,
56 },
59 },
57
60
58 Llama3_3_70BVersatile: {
61 Llama3_3_70BVersatile: {
59− ID: Llama3_3_70BVersatile,
62+ ID: Llama3_3_70BVersatile,
60− Name: "Llama3_3_70BVersatile",
63+ Name: "Llama3_3_70BVersatile",
61− Provider: ProviderGROQ,
64+ Provider: ProviderGROQ,
62− APIModel: "llama-3.3-70b-versatile",
65+ APIModel: "llama-3.3-70b-versatile",
63− CostPer1MIn: 0.59,
66+ CostPer1MIn: 0.59,
64− CostPer1MInCached: 0,
67+ CostPer1MInCached: 0,
65− CostPer1MOutCached: 0,
68+ CostPer1MOutCached: 0,
66− CostPer1MOut: 0.79,
69+ CostPer1MOut: 0.79,
67− ContextWindow: 128_000,
70+ ContextWindow: 128_000,
71+ SupportsAttachments: false,
68 },
72 },
69
73
70 DeepseekR1DistillLlama70b: {
74 DeepseekR1DistillLlama70b: {
71− ID: DeepseekR1DistillLlama70b,
75+ ID: DeepseekR1DistillLlama70b,
72− Name: "DeepseekR1DistillLlama70b",
76+ Name: "DeepseekR1DistillLlama70b",
73− Provider: ProviderGROQ,
77+ Provider: ProviderGROQ,
74− APIModel: "deepseek-r1-distill-llama-70b",
78+ APIModel: "deepseek-r1-distill-llama-70b",
75− CostPer1MIn: 0.75,
79+ CostPer1MIn: 0.75,
76− CostPer1MInCached: 0,
80+ CostPer1MInCached: 0,
77− CostPer1MOutCached: 0,
81+ CostPer1MOutCached: 0,
78− CostPer1MOut: 0.99,
82+ CostPer1MOut: 0.99,
79− ContextWindow: 128_000,
83+ ContextWindow: 128_000,
80− CanReason: true,
84+ CanReason: true,
85+ SupportsAttachments: false,
81 },
86 },
82}
87}
internal/llm/models/models.go+12−11
@@ -8,17 +8,18 @@ type (
8)
8)
9
9
10type Model struct {
10type Model struct {
11− ID ModelID `json:"id"`
11+ ID ModelID `json:"id"`
12− Name string `json:"name"`
12+ Name string `json:"name"`
13− Provider ModelProvider `json:"provider"`
13+ Provider ModelProvider `json:"provider"`
14− APIModel string `json:"api_model"`
14+ APIModel string `json:"api_model"`
15− CostPer1MIn float64 `json:"cost_per_1m_in"`
15+ CostPer1MIn float64 `json:"cost_per_1m_in"`
16− CostPer1MOut float64 `json:"cost_per_1m_out"`
16+ CostPer1MOut float64 `json:"cost_per_1m_out"`
17− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
17+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
18+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19− ContextWindow int64 `json:"context_window"`
19+ ContextWindow int64 `json:"context_window"`
20− DefaultMaxTokens int64 `json:"default_max_tokens"`
20+ DefaultMaxTokens int64 `json:"default_max_tokens"`
21− CanReason bool `json:"can_reason"`
21+ CanReason bool `json:"can_reason"`
22+ SupportsAttachments bool `json:"supports_attachments"`
22}
23}
23
24
24// Model IDs
25// Model IDs
internal/llm/models/openai.go+136−124
@@ -19,151 +19,163 @@ const (
19
19
20var OpenAIModels = map[ModelID]Model{
20var OpenAIModels = map[ModelID]Model{
21 GPT41: {
21 GPT41: {
22− ID: GPT41,
22+ ID: GPT41,
23− Name: "GPT 4.1",
23+ Name: "GPT 4.1",
24− Provider: ProviderOpenAI,
24+ Provider: ProviderOpenAI,
25− APIModel: "gpt-4.1",
25+ APIModel: "gpt-4.1",
26− CostPer1MIn: 2.00,
26+ CostPer1MIn: 2.00,
27− CostPer1MInCached: 0.50,
27+ CostPer1MInCached: 0.50,
28− CostPer1MOutCached: 0.0,
28+ CostPer1MOutCached: 0.0,
29− CostPer1MOut: 8.00,
29+ CostPer1MOut: 8.00,
30− ContextWindow: 1_047_576,
30+ ContextWindow: 1_047_576,
31− DefaultMaxTokens: 20000,
31+ DefaultMaxTokens: 20000,
32+ SupportsAttachments: true,
32 },
33 },
33 GPT41Mini: {
34 GPT41Mini: {
34− ID: GPT41Mini,
35+ ID: GPT41Mini,
35− Name: "GPT 4.1 mini",
36+ Name: "GPT 4.1 mini",
36− Provider: ProviderOpenAI,
37+ Provider: ProviderOpenAI,
37− APIModel: "gpt-4.1",
38+ APIModel: "gpt-4.1",
38− CostPer1MIn: 0.40,
39+ CostPer1MIn: 0.40,
39− CostPer1MInCached: 0.10,
40+ CostPer1MInCached: 0.10,
40− CostPer1MOutCached: 0.0,
41+ CostPer1MOutCached: 0.0,
41− CostPer1MOut: 1.60,
42+ CostPer1MOut: 1.60,
42− ContextWindow: 200_000,
43+ ContextWindow: 200_000,
43− DefaultMaxTokens: 20000,
44+ DefaultMaxTokens: 20000,
45+ SupportsAttachments: true,
44 },
46 },
45 GPT41Nano: {
47 GPT41Nano: {
46− ID: GPT41Nano,
48+ ID: GPT41Nano,
47− Name: "GPT 4.1 nano",
49+ Name: "GPT 4.1 nano",
48− Provider: ProviderOpenAI,
50+ Provider: ProviderOpenAI,
49− APIModel: "gpt-4.1-nano",
51+ APIModel: "gpt-4.1-nano",
50− CostPer1MIn: 0.10,
52+ CostPer1MIn: 0.10,
51− CostPer1MInCached: 0.025,
53+ CostPer1MInCached: 0.025,
52− CostPer1MOutCached: 0.0,
54+ CostPer1MOutCached: 0.0,
53− CostPer1MOut: 0.40,
55+ CostPer1MOut: 0.40,
54− ContextWindow: 1_047_576,
56+ ContextWindow: 1_047_576,
55− DefaultMaxTokens: 20000,
57+ DefaultMaxTokens: 20000,
58+ SupportsAttachments: true,
56 },
59 },
57 GPT45Preview: {
60 GPT45Preview: {
58− ID: GPT45Preview,
61+ ID: GPT45Preview,
59− Name: "GPT 4.5 preview",
62+ Name: "GPT 4.5 preview",
60− Provider: ProviderOpenAI,
63+ Provider: ProviderOpenAI,
61− APIModel: "gpt-4.5-preview",
64+ APIModel: "gpt-4.5-preview",
62− CostPer1MIn: 75.00,
65+ CostPer1MIn: 75.00,
63− CostPer1MInCached: 37.50,
66+ CostPer1MInCached: 37.50,
64− CostPer1MOutCached: 0.0,
67+ CostPer1MOutCached: 0.0,
65− CostPer1MOut: 150.00,
68+ CostPer1MOut: 150.00,
66− ContextWindow: 128_000,
69+ ContextWindow: 128_000,
67− DefaultMaxTokens: 15000,
70+ DefaultMaxTokens: 15000,
71+ SupportsAttachments: true,
68 },
72 },
69 GPT4o: {
73 GPT4o: {
70− ID: GPT4o,
74+ ID: GPT4o,
71− Name: "GPT 4o",
75+ Name: "GPT 4o",
72− Provider: ProviderOpenAI,
76+ Provider: ProviderOpenAI,
73− APIModel: "gpt-4o",
77+ APIModel: "gpt-4o",
74− CostPer1MIn: 2.50,
78+ CostPer1MIn: 2.50,
75− CostPer1MInCached: 1.25,
79+ CostPer1MInCached: 1.25,
76− CostPer1MOutCached: 0.0,
80+ CostPer1MOutCached: 0.0,
77− CostPer1MOut: 10.00,
81+ CostPer1MOut: 10.00,
78− ContextWindow: 128_000,
82+ ContextWindow: 128_000,
79− DefaultMaxTokens: 4096,
83+ DefaultMaxTokens: 4096,
84+ SupportsAttachments: true,
80 },
85 },
81 GPT4oMini: {
86 GPT4oMini: {
82− ID: GPT4oMini,
87+ ID: GPT4oMini,
83− Name: "GPT 4o mini",
88+ Name: "GPT 4o mini",
84− Provider: ProviderOpenAI,
89+ Provider: ProviderOpenAI,
85− APIModel: "gpt-4o-mini",
90+ APIModel: "gpt-4o-mini",
86− CostPer1MIn: 0.15,
91+ CostPer1MIn: 0.15,
87− CostPer1MInCached: 0.075,
92+ CostPer1MInCached: 0.075,
88− CostPer1MOutCached: 0.0,
93+ CostPer1MOutCached: 0.0,
89− CostPer1MOut: 0.60,
94+ CostPer1MOut: 0.60,
90− ContextWindow: 128_000,
95+ ContextWindow: 128_000,
96+ SupportsAttachments: true,
91 },
97 },
92 O1: {
98 O1: {
93− ID: O1,
99+ ID: O1,
94− Name: "O1",
100+ Name: "O1",
95− Provider: ProviderOpenAI,
101+ Provider: ProviderOpenAI,
96− APIModel: "o1",
102+ APIModel: "o1",
97− CostPer1MIn: 15.00,
103+ CostPer1MIn: 15.00,
98− CostPer1MInCached: 7.50,
104+ CostPer1MInCached: 7.50,
99− CostPer1MOutCached: 0.0,
105+ CostPer1MOutCached: 0.0,
100− CostPer1MOut: 60.00,
106+ CostPer1MOut: 60.00,
101− ContextWindow: 200_000,
107+ ContextWindow: 200_000,
102− DefaultMaxTokens: 50000,
108+ DefaultMaxTokens: 50000,
103− CanReason: true,
109+ CanReason: true,
110+ SupportsAttachments: true,
104 },
111 },
105 O1Pro: {
112 O1Pro: {
106− ID: O1Pro,
113+ ID: O1Pro,
107− Name: "o1 pro",
114+ Name: "o1 pro",
108− Provider: ProviderOpenAI,
115+ Provider: ProviderOpenAI,
109− APIModel: "o1-pro",
116+ APIModel: "o1-pro",
110− CostPer1MIn: 150.00,
117+ CostPer1MIn: 150.00,
111− CostPer1MInCached: 0.0,
118+ CostPer1MInCached: 0.0,
112− CostPer1MOutCached: 0.0,
119+ CostPer1MOutCached: 0.0,
113− CostPer1MOut: 600.00,
120+ CostPer1MOut: 600.00,
114− ContextWindow: 200_000,
121+ ContextWindow: 200_000,
115− DefaultMaxTokens: 50000,
122+ DefaultMaxTokens: 50000,
116− CanReason: true,
123+ CanReason: true,
124+ SupportsAttachments: true,
117 },
125 },
118 O1Mini: {
126 O1Mini: {
119− ID: O1Mini,
127+ ID: O1Mini,
120− Name: "o1 mini",
128+ Name: "o1 mini",
121− Provider: ProviderOpenAI,
129+ Provider: ProviderOpenAI,
122− APIModel: "o1-mini",
130+ APIModel: "o1-mini",
123− CostPer1MIn: 1.10,
131+ CostPer1MIn: 1.10,
124− CostPer1MInCached: 0.55,
132+ CostPer1MInCached: 0.55,
125− CostPer1MOutCached: 0.0,
133+ CostPer1MOutCached: 0.0,
126− CostPer1MOut: 4.40,
134+ CostPer1MOut: 4.40,
127− ContextWindow: 128_000,
135+ ContextWindow: 128_000,
128− DefaultMaxTokens: 50000,
136+ DefaultMaxTokens: 50000,
129− CanReason: true,
137+ CanReason: true,
138+ SupportsAttachments: true,
130 },
139 },
131 O3: {
140 O3: {
132− ID: O3,
141+ ID: O3,
133− Name: "o3",
142+ Name: "o3",
134− Provider: ProviderOpenAI,
143+ Provider: ProviderOpenAI,
135− APIModel: "o3",
144+ APIModel: "o3",
136− CostPer1MIn: 10.00,
145+ CostPer1MIn: 10.00,
137− CostPer1MInCached: 2.50,
146+ CostPer1MInCached: 2.50,
138− CostPer1MOutCached: 0.0,
147+ CostPer1MOutCached: 0.0,
139− CostPer1MOut: 40.00,
148+ CostPer1MOut: 40.00,
140− ContextWindow: 200_000,
149+ ContextWindow: 200_000,
141− CanReason: true,
150+ CanReason: true,
151+ SupportsAttachments: true,
142 },
152 },
143 O3Mini: {
153 O3Mini: {
144− ID: O3Mini,
154+ ID: O3Mini,
145− Name: "o3 mini",
155+ Name: "o3 mini",
146− Provider: ProviderOpenAI,
156+ Provider: ProviderOpenAI,
147− APIModel: "o3-mini",
157+ APIModel: "o3-mini",
148− CostPer1MIn: 1.10,
158+ CostPer1MIn: 1.10,
149− CostPer1MInCached: 0.55,
159+ CostPer1MInCached: 0.55,
150− CostPer1MOutCached: 0.0,
160+ CostPer1MOutCached: 0.0,
151− CostPer1MOut: 4.40,
161+ CostPer1MOut: 4.40,
152− ContextWindow: 200_000,
162+ ContextWindow: 200_000,
153− DefaultMaxTokens: 50000,
163+ DefaultMaxTokens: 50000,
154− CanReason: true,
164+ CanReason: true,
165+ SupportsAttachments: false,
155 },
166 },
156 O4Mini: {
167 O4Mini: {
157− ID: O4Mini,
168+ ID: O4Mini,
158− Name: "o4 mini",
169+ Name: "o4 mini",
159− Provider: ProviderOpenAI,
170+ Provider: ProviderOpenAI,
160− APIModel: "o4-mini",
171+ APIModel: "o4-mini",
161− CostPer1MIn: 1.10,
172+ CostPer1MIn: 1.10,
162− CostPer1MInCached: 0.275,
173+ CostPer1MInCached: 0.275,
163− CostPer1MOutCached: 0.0,
174+ CostPer1MOutCached: 0.0,
164− CostPer1MOut: 4.40,
175+ CostPer1MOut: 4.40,
165− ContextWindow: 128_000,
176+ ContextWindow: 128_000,
166− DefaultMaxTokens: 50000,
177+ DefaultMaxTokens: 50000,
167− CanReason: true,
178+ CanReason: true,
179+ SupportsAttachments: true,
168 },
180 },
169}
181}
internal/llm/prompt/title.go+2−1
@@ -8,5 +8,6 @@ func TitlePrompt(_ models.ModelProvider) string {
8- the title should be a summary of the user's message
8- the title should be a summary of the user's message
9- it should be one line long
9- it should be one line long
10- do not use quotes or colons
10- do not use quotes or colons
11−- the entire text you return will be used as the title`
11+- the entire text you return will be used as the title
12+- never return anything that is more than one sentence (one line) long`
12}
13}
internal/llm/provider/anthropic.go+13−3
@@ -13,6 +13,7 @@ import (
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/opencode-ai/opencode/internal/config"
15 "github.com/opencode-ai/opencode/internal/config"
16+ "github.com/opencode-ai/opencode/internal/llm/models"
16 "github.com/opencode-ai/opencode/internal/llm/tools"
17 "github.com/opencode-ai/opencode/internal/llm/tools"
17 "github.com/opencode-ai/opencode/internal/logging"
18 "github.com/opencode-ai/opencode/internal/logging"
18 "github.com/opencode-ai/opencode/internal/message"
19 "github.com/opencode-ai/opencode/internal/message"
@@ -70,7 +71,14 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
70 Type: "ephemeral",
71 Type: "ephemeral",
71 }
72 }
72 }
73 }
74+ var contentBlocks []anthropic.ContentBlockParamUnion
75+ contentBlocks = append(contentBlocks, content)
76+ for _, binaryContent := range msg.BinaryContent() {
77+ base64Image := binaryContent.String(models.ProviderAnthropic)
78+ imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
79+ contentBlocks = append(contentBlocks, imageBlock)
80+ }
73− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
81+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
74
82
75 case message.Assistant:
83 case message.Assistant:
76 blocks := []anthropic.ContentBlockParamUnion{}
84 blocks := []anthropic.ContentBlockParamUnion{}
@@ -196,9 +204,10 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
196 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
204 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
197 cfg := config.Get()
205 cfg := config.Get()
198 if cfg.Debug {
206 if cfg.Debug {
199− // jsonData, _ := json.Marshal(preparedMessages)
207+ jsonData, _ := json.Marshal(preparedMessages)
200− // logging.Debug("Prepared messages", "messages", string(jsonData))
208+ logging.Debug("Prepared messages", "messages", string(jsonData))
201 }
209 }
210+
202 attempts := 0
211 attempts := 0
203 for {
212 for {
204 attempts++
213 attempts++
@@ -208,6 +217,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
208 )
217 )
209 // If there is an error we are going to see if we can retry the call
218 // If there is an error we are going to see if we can retry the call
210 if err != nil {
219 if err != nil {
220+ logging.Error("Error in Anthropic API call", "error", err)
211 retry, after, retryErr := a.shouldRetry(attempts, err)
221 retry, after, retryErr := a.shouldRetry(attempts, err)
212 if retryErr != nil {
222 if retryErr != nil {
213 return nil, retryErr
223 return nil, retryErr
internal/llm/provider/bedrock.go+5−4
@@ -55,7 +55,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
55 if strings.Contains(string(opts.model.APIModel), "anthropic") {
55 if strings.Contains(string(opts.model.APIModel), "anthropic") {
56 // Create Anthropic client with Bedrock configuration
56 // Create Anthropic client with Bedrock configuration
57 anthropicOpts := opts
57 anthropicOpts := opts
58− anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
58+ anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
59 WithAnthropicBedrock(true),
59 WithAnthropicBedrock(true),
60 WithAnthropicDisableCache(),
60 WithAnthropicDisableCache(),
61 )
61 )
@@ -84,7 +84,7 @@ func (b *bedrockClient) send(ctx context.Context, messages []message.Message, to
84
84
85func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
85func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
86 eventChan := make(chan ProviderEvent)
86 eventChan := make(chan ProviderEvent)
87−
87+
88 if b.childProvider == nil {
88 if b.childProvider == nil {
89 go func() {
89 go func() {
90 eventChan <- ProviderEvent{
90 eventChan <- ProviderEvent{
@@ -95,6 +95,7 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
95 }()
95 }()
96 return eventChan
96 return eventChan
97 }
97 }
98−
98+
99 return b.childProvider.stream(ctx, messages, tools)
99 return b.childProvider.stream(ctx, messages, tools)
100−}
100+}
101+
internal/llm/provider/gemini.go+7−2
@@ -57,11 +57,16 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
57 for _, msg := range messages {
57 for _, msg := range messages {
58 switch msg.Role {
58 switch msg.Role {
59 case message.User:
59 case message.User:
60+ var parts []genai.Part
61+ parts = append(parts, genai.Text(msg.Content().String()))
62+ for _, binaryContent := range msg.BinaryContent() {
63+ imageFormat := strings.Split(binaryContent.MIMEType, "/")
64+ parts = append(parts, genai.ImageData(imageFormat[1], binaryContent.Data))
65+ }
60 history = append(history, &genai.Content{
66 history = append(history, &genai.Content{
61− Parts: []genai.Part{genai.Text(msg.Content().String())},
67+ Parts: parts,
62 Role: "user",
68 Role: "user",
63 })
69 })
64−
65 case message.Assistant:
70 case message.Assistant:
66 content := &genai.Content{
71 content := &genai.Content{
67 Role: "model",
72 Role: "model",
internal/llm/provider/openai.go+12−1
@@ -12,6 +12,7 @@ import (
12 "github.com/openai/openai-go/option"
12 "github.com/openai/openai-go/option"
13 "github.com/openai/openai-go/shared"
13 "github.com/openai/openai-go/shared"
14 "github.com/opencode-ai/opencode/internal/config"
14 "github.com/opencode-ai/opencode/internal/config"
15+ "github.com/opencode-ai/opencode/internal/llm/models"
15 "github.com/opencode-ai/opencode/internal/llm/tools"
16 "github.com/opencode-ai/opencode/internal/llm/tools"
16 "github.com/opencode-ai/opencode/internal/logging"
17 "github.com/opencode-ai/opencode/internal/logging"
17 "github.com/opencode-ai/opencode/internal/message"
18 "github.com/opencode-ai/opencode/internal/message"
@@ -71,7 +72,17 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
71 for _, msg := range messages {
72 for _, msg := range messages {
72 switch msg.Role {
73 switch msg.Role {
73 case message.User:
74 case message.User:
75+ var content []openai.ChatCompletionContentPartUnionParam
76+ textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
77+ content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
78+ for _, binaryContent := range msg.BinaryContent() {
79+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
80+ imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
81+
82+ content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
83+ }
84+
74− openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
85+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
75
86
76 case message.Assistant:
87 case message.Assistant:
77 assistantMsg := openai.ChatCompletionAssistantMessageParam{
88 assistantMsg := openai.ChatCompletionAssistantMessageParam{
internal/message/attachment.gonew file+8
@@ -0,0 +1,8 @@
1+package message
2+
3+type Attachment struct {
4+ FilePath string
5+ FileName string
6+ MimeType string
7+ Content []byte
8+}
internal/message/content.go+6−3
@@ -66,13 +66,17 @@ func (iuc ImageURLContent) String() string {
66func (ImageURLContent) isPart() {}
66func (ImageURLContent) isPart() {}
67
67
68type BinaryContent struct {
68type BinaryContent struct {
69+ Path string
69 MIMEType string
70 MIMEType string
70 Data []byte
71 Data []byte
71}
72}
72
73
73−func (bc BinaryContent) String() string {
74+func (bc BinaryContent) String(provider models.ModelProvider) string {
74 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
75 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
76+ if provider == models.ProviderOpenAI {
75− return "data:" + bc.MIMEType + ";base64," + base64Encoded
77+ return "data:" + bc.MIMEType + ";base64," + base64Encoded
78+ }
79+ return base64Encoded
76}
80}
77
81
78func (BinaryContent) isPart() {}
82func (BinaryContent) isPart() {}
@@ -110,7 +114,6 @@ type Message struct {
110 SessionID string
114 SessionID string
111 Parts []ContentPart
115 Parts []ContentPart
112 Model models.ModelID
116 Model models.ModelID
113−
114 CreatedAt int64
117 CreatedAt int64
115 UpdatedAt int64
118 UpdatedAt int64
116}
119}
internal/message/message.go−1
@@ -64,7 +64,6 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
64 if err != nil {
64 if err != nil {
65 return Message{}, err
65 return Message{}, err
66 }
66 }
67−
68 dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
67 dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
69 ID: uuid.New().String(),
68 ID: uuid.New().String(),
70 SessionID: sessionID,
69 SessionID: sessionID,
internal/tui/components/chat/chat.go+3−1
@@ -7,6 +7,7 @@ import (
7 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
9 "github.com/opencode-ai/opencode/internal/config"
9 "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/opencode-ai/opencode/internal/message"
10 "github.com/opencode-ai/opencode/internal/session"
11 "github.com/opencode-ai/opencode/internal/session"
11 "github.com/opencode-ai/opencode/internal/tui/styles"
12 "github.com/opencode-ai/opencode/internal/tui/styles"
12 "github.com/opencode-ai/opencode/internal/tui/theme"
13 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -14,7 +15,8 @@ import (
14)
15)
15
16
16type SendMsg struct {
17type SendMsg struct {
17− Text string
18+ Text string
19+ Attachments []message.Attachment
18}
20}
19
21
20type SessionSelectedMsg = session.Session
22type SessionSelectedMsg = session.Session
internal/tui/components/chat/editor.go+115−10
@@ -1,14 +1,19 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "fmt"
4 "os"
5 "os"
5 "os/exec"
6 "os/exec"
7+ "slices"
8+ "unicode"
6
9
7 "github.com/charmbracelet/bubbles/key"
10 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/textarea"
11 "github.com/charmbracelet/bubbles/textarea"
9 tea "github.com/charmbracelet/bubbletea"
12 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
13 "github.com/charmbracelet/lipgloss"
11 "github.com/opencode-ai/opencode/internal/app"
14 "github.com/opencode-ai/opencode/internal/app"
15+ "github.com/opencode-ai/opencode/internal/logging"
16+ "github.com/opencode-ai/opencode/internal/message"
12 "github.com/opencode-ai/opencode/internal/session"
17 "github.com/opencode-ai/opencode/internal/session"
13 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
18 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
14 "github.com/opencode-ai/opencode/internal/tui/layout"
19 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -18,9 +23,13 @@ import (
18)
23)
19
24
20type editorCmp struct {
25type editorCmp struct {
26+ width int
27+ height int
21− app *app.App
28+ app *app.App
22− session session.Session
29+ session session.Session
23− textarea textarea.Model
30+ textarea textarea.Model
31+ attachments []message.Attachment
32+ deleteMode bool
24}
33}
25
34
26type EditorKeyMaps struct {
35type EditorKeyMaps struct {
@@ -33,6 +42,11 @@ type bluredEditorKeyMaps struct {
33 Focus key.Binding
42 Focus key.Binding
34 OpenEditor key.Binding
43 OpenEditor key.Binding
35}
44}
45+type DeleteAttachmentKeyMaps struct {
46+ AttachmentDeleteMode key.Binding
47+ Escape key.Binding
48+ DeleteAllAttachments key.Binding
49+}
36
50
37var editorMaps = EditorKeyMaps{
51var editorMaps = EditorKeyMaps{
38 Send: key.NewBinding(
52 Send: key.NewBinding(
@@ -45,7 +59,26 @@ var editorMaps = EditorKeyMaps{
45 ),
59 ),
46}
60}
47
61
62+var DeleteKeyMaps = DeleteAttachmentKeyMaps{
63+ AttachmentDeleteMode: key.NewBinding(
64+ key.WithKeys("ctrl+r"),
65+ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
66+ ),
67+ Escape: key.NewBinding(
68+ key.WithKeys("esc"),
69+ key.WithHelp("esc", "cancel delete mode"),
70+ ),
71+ DeleteAllAttachments: key.NewBinding(
72+ key.WithKeys("r"),
73+ key.WithHelp("ctrl+r+r", "delete all attchments"),
74+ ),
75+}
76+
77+const (
78+ maxAttachments = 5
79+)
80+
48−func openEditor() tea.Cmd {
81+func (m *editorCmp) openEditor() tea.Cmd {
49 editor := os.Getenv("EDITOR")
82 editor := os.Getenv("EDITOR")
50 if editor == "" {
83 if editor == "" {
51 editor = "nvim"
84 editor = "nvim"
@@ -72,8 +105,11 @@ func openEditor() tea.Cmd {
72 return util.ReportWarn("Message is empty")
105 return util.ReportWarn("Message is empty")
73 }
106 }
74 os.Remove(tmpfile.Name())
107 os.Remove(tmpfile.Name())
108+ attachments := m.attachments
109+ m.attachments = nil
75 return SendMsg{
110 return SendMsg{
76− Text: string(content),
111+ Text: string(content),
112+ Attachments: attachments,
77 }
113 }
78 })
114 })
79}
115}
@@ -89,12 +125,16 @@ func (m *editorCmp) send() tea.Cmd {
89
125
90 value := m.textarea.Value()
126 value := m.textarea.Value()
91 m.textarea.Reset()
127 m.textarea.Reset()
128+ attachments := m.attachments
129+
130+ m.attachments = nil
92 if value == "" {
131 if value == "" {
93 return nil
132 return nil
94 }
133 }
95 return tea.Batch(
134 return tea.Batch(
96 util.CmdHandler(SendMsg{
135 util.CmdHandler(SendMsg{
97− Text: value,
136+ Text: value,
137+ Attachments: attachments,
98 }),
138 }),
99 )
139 )
100}
140}
@@ -110,7 +150,34 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110 m.session = msg
150 m.session = msg
111 }
151 }
112 return m, nil
152 return m, nil
153+ case dialog.AttachmentAddedMsg:
154+ if len(m.attachments) >= maxAttachments {
155+ logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
156+ return m, cmd
157+ }
158+ m.attachments = append(m.attachments, msg.Attachment)
113 case tea.KeyMsg:
159 case tea.KeyMsg:
160+ if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
161+ m.deleteMode = true
162+ return m, nil
163+ }
164+ if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
165+ m.deleteMode = false
166+ m.attachments = nil
167+ return m, nil
168+ }
169+ if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
170+ num := int(msg.Runes[0] - '0')
171+ m.deleteMode = false
172+ if num < 10 && len(m.attachments) > num {
173+ if num == 0 {
174+ m.attachments = m.attachments[num+1:]
175+ } else {
176+ m.attachments = slices.Delete(m.attachments, num, num+1)
177+ }
178+ return m, nil
179+ }
180+ }
114 if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
181 if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
115 key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
182 key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
116 return m, nil
183 return m, nil
@@ -119,7 +186,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
186 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
120 return m, util.ReportWarn("Agent is working, please wait...")
187 return m, util.ReportWarn("Agent is working, please wait...")
121 }
188 }
122− return m, openEditor()
189+ return m, m.openEditor()
190+ }
191+ if key.Matches(msg, DeleteKeyMaps.Escape) {
192+ m.deleteMode = false
193+ return m, nil
123 }
194 }
124 // Handle Enter key
195 // Handle Enter key
125 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
196 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
@@ -133,6 +204,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
133 return m, m.send()
204 return m, m.send()
134 }
205 }
135 }
206 }
207+
136 }
208 }
137 m.textarea, cmd = m.textarea.Update(msg)
209 m.textarea, cmd = m.textarea.Update(msg)
138 return m, cmd
210 return m, cmd
@@ -147,12 +219,23 @@ func (m *editorCmp) View() string {
147 Bold(true).
219 Bold(true).
148 Foreground(t.Primary())
220 Foreground(t.Primary())
149
221
222+ if len(m.attachments) == 0 {
150− return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
223+ return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
224+ }
225+ m.textarea.SetHeight(m.height - 1)
226+ return lipgloss.JoinVertical(lipgloss.Top,
227+ m.attachmentsContent(),
228+ lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
229+ m.textarea.View()),
230+ )
151}
231}
152
232
153func (m *editorCmp) SetSize(width, height int) tea.Cmd {
233func (m *editorCmp) SetSize(width, height int) tea.Cmd {
234+ m.width = width
235+ m.height = height
154 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
236 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
155 m.textarea.SetHeight(height)
237 m.textarea.SetHeight(height)
238+ m.textarea.SetWidth(width)
156 return nil
239 return nil
157}
240}
158
241
@@ -160,9 +243,33 @@ func (m *editorCmp) GetSize() (int, int) {
160 return m.textarea.Width(), m.textarea.Height()
243 return m.textarea.Width(), m.textarea.Height()
161}
244}
162
245
246+func (m *editorCmp) attachmentsContent() string {
247+ var styledAttachments []string
248+ t := theme.CurrentTheme()
249+ attachmentStyles := styles.BaseStyle().
250+ MarginLeft(1).
251+ Background(t.TextMuted()).
252+ Foreground(t.Text())
253+ for i, attachment := range m.attachments {
254+ var filename string
255+ if len(attachment.FileName) > 10 {
256+ filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
257+ } else {
258+ filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
259+ }
260+ if m.deleteMode {
261+ filename = fmt.Sprintf("%d%s", i, filename)
262+ }
263+ styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
264+ }
265+ content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
266+ return content
267+}
268+
163func (m *editorCmp) BindingKeys() []key.Binding {
269func (m *editorCmp) BindingKeys() []key.Binding {
164 bindings := []key.Binding{}
270 bindings := []key.Binding{}
165 bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
271 bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
272+ bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
166 return bindings
273 return bindings
167}
274}
168
275
@@ -198,10 +305,8 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
198
305
199func NewEditorCmp(app *app.App) tea.Model {
306func NewEditorCmp(app *app.App) tea.Model {
200 ta := CreateTextArea(nil)
307 ta := CreateTextArea(nil)
201−
202 return &editorCmp{
308 return &editorCmp{
203 app: app,
309 app: app,
204 textarea: ta,
310 textarea: ta,
205 }
311 }
206}
312}
207−
internal/tui/components/chat/list.go+13−3
@@ -35,6 +35,7 @@ type messagesCmp struct {
35 cachedContent map[string]cacheItem
35 cachedContent map[string]cacheItem
36 spinner spinner.Model
36 spinner spinner.Model
37 rendering bool
37 rendering bool
38+ attachments viewport.Model
38}
39}
39type renderFinishedMsg struct{}
40type renderFinishedMsg struct{}
40
41
@@ -229,12 +230,15 @@ func (m *messagesCmp) renderView() {
229
230
230 messages := make([]string, 0)
231 messages := make([]string, 0)
231 for _, v := range m.uiMessages {
232 for _, v := range m.uiMessages {
232− messages = append(messages, v.content,
233+ messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
233 baseStyle.
234 baseStyle.
234 Width(m.width).
235 Width(m.width).
235− Render(""),
236+ Render(
237+ "",
238+ ),
236 )
239 )
237 }
240 }
241+
238 m.viewport.SetContent(
242 m.viewport.SetContent(
239 baseStyle.
243 baseStyle.
240 Width(m.width).
244 Width(m.width).
@@ -413,6 +417,8 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
413 m.height = height
417 m.height = height
414 m.viewport.Width = width
418 m.viewport.Width = width
415 m.viewport.Height = height - 2
419 m.viewport.Height = height - 2
420+ m.attachments.Width = width + 40
421+ m.attachments.Height = 3
416 m.rerender()
422 m.rerender()
417 return nil
423 return nil
418}
424}
@@ -431,7 +437,9 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
431 return util.ReportError(err)
437 return util.ReportError(err)
432 }
438 }
433 m.messages = messages
439 m.messages = messages
440+ if len(m.messages) > 0 {
434− m.currentMsgID = m.messages[len(m.messages)-1].ID
441+ m.currentMsgID = m.messages[len(m.messages)-1].ID
442+ }
435 delete(m.cachedContent, m.currentMsgID)
443 delete(m.cachedContent, m.currentMsgID)
436 m.rendering = true
444 m.rendering = true
437 return func() tea.Msg {
445 return func() tea.Msg {
@@ -453,6 +461,7 @@ func NewMessagesCmp(app *app.App) tea.Model {
453 s := spinner.New()
461 s := spinner.New()
454 s.Spinner = spinner.Pulse
462 s.Spinner = spinner.Pulse
455 vp := viewport.New(0, 0)
463 vp := viewport.New(0, 0)
464+ attachmets := viewport.New(0, 0)
456 vp.KeyMap.PageUp = messageKeys.PageUp
465 vp.KeyMap.PageUp = messageKeys.PageUp
457 vp.KeyMap.PageDown = messageKeys.PageDown
466 vp.KeyMap.PageDown = messageKeys.PageDown
458 vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
467 vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
@@ -462,5 +471,6 @@ func NewMessagesCmp(app *app.App) tea.Model {
462 cachedContent: make(map[string]cacheItem),
471 cachedContent: make(map[string]cacheItem),
463 viewport: vp,
472 viewport: vp,
464 spinner: s,
473 spinner: s,
474+ attachments: attachmets,
465 }
475 }
466}
476}
internal/tui/components/chat/message.go+23−1
@@ -80,7 +80,29 @@ func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...s
80}
80}
81
81
82func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
82func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
83+ var styledAttachments []string
84+ t := theme.CurrentTheme()
85+ attachmentStyles := styles.BaseStyle().
86+ MarginLeft(1).
87+ Background(t.TextMuted()).
88+ Foreground(t.Text())
89+ for _, attachment := range msg.BinaryContent() {
90+ file := filepath.Base(attachment.Path)
91+ var filename string
92+ if len(file) > 10 {
93+ filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
94+ } else {
95+ filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
96+ }
97+ styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
98+ }
99+ content := ""
100+ if len(styledAttachments) > 0 {
101+ attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
102+ content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
103+ } else {
83− content := renderMessage(msg.Content().String(), true, isFocused, width)
104+ content = renderMessage(msg.Content().String(), true, isFocused, width)
105+ }
84 userMsg := uiMessage{
106 userMsg := uiMessage{
85 ID: msg.ID,
107 ID: msg.ID,
86 messageType: userMessageType,
108 messageType: userMessageType,
internal/tui/components/dialog/filepicker.gonew file+477
@@ -0,0 +1,477 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/charmbracelet/bubbles/key"
13+ "github.com/charmbracelet/bubbles/textinput"
14+ "github.com/charmbracelet/bubbles/viewport"
15+ tea "github.com/charmbracelet/bubbletea"
16+ "github.com/charmbracelet/lipgloss"
17+ "github.com/opencode-ai/opencode/internal/app"
18+ "github.com/opencode-ai/opencode/internal/config"
19+ "github.com/opencode-ai/opencode/internal/logging"
20+ "github.com/opencode-ai/opencode/internal/message"
21+ "github.com/opencode-ai/opencode/internal/tui/image"
22+ "github.com/opencode-ai/opencode/internal/tui/styles"
23+ "github.com/opencode-ai/opencode/internal/tui/theme"
24+ "github.com/opencode-ai/opencode/internal/tui/util"
25+)
26+
27+const (
28+ maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
29+ downArrow = "down"
30+ upArrow = "up"
31+)
32+
33+type FilePrickerKeyMap struct {
34+ Enter key.Binding
35+ Down key.Binding
36+ Up key.Binding
37+ Forward key.Binding
38+ Backward key.Binding
39+ OpenFilePicker key.Binding
40+ Esc key.Binding
41+ InsertCWD key.Binding
42+}
43+
44+var filePickerKeyMap = FilePrickerKeyMap{
45+ Enter: key.NewBinding(
46+ key.WithKeys("enter"),
47+ key.WithHelp("enter", "select file/enter directory"),
48+ ),
49+ Down: key.NewBinding(
50+ key.WithKeys("j", downArrow),
51+ key.WithHelp("↓/j", "down"),
52+ ),
53+ Up: key.NewBinding(
54+ key.WithKeys("k", upArrow),
55+ key.WithHelp("↑/k", "up"),
56+ ),
57+ Forward: key.NewBinding(
58+ key.WithKeys("l"),
59+ key.WithHelp("l", "enter directory"),
60+ ),
61+ Backward: key.NewBinding(
62+ key.WithKeys("h", "backspace"),
63+ key.WithHelp("h/backspace", "go back"),
64+ ),
65+ OpenFilePicker: key.NewBinding(
66+ key.WithKeys("ctrl+f"),
67+ key.WithHelp("ctrl+f", "open file picker"),
68+ ),
69+ Esc: key.NewBinding(
70+ key.WithKeys("esc"),
71+ key.WithHelp("esc", "close/exit"),
72+ ),
73+ InsertCWD: key.NewBinding(
74+ key.WithKeys("i"),
75+ key.WithHelp("i", "manual path input"),
76+ ),
77+}
78+
79+type filepickerCmp struct {
80+ basePath string
81+ width int
82+ height int
83+ cursor int
84+ err error
85+ cursorChain stack
86+ viewport viewport.Model
87+ dirs []os.DirEntry
88+ cwdDetails *DirNode
89+ selectedFile string
90+ cwd textinput.Model
91+ ShowFilePicker bool
92+ app *app.App
93+}
94+
95+type DirNode struct {
96+ parent *DirNode
97+ child *DirNode
98+ directory string
99+}
100+type stack []int
101+
102+func (s stack) Push(v int) stack {
103+ return append(s, v)
104+}
105+
106+func (s stack) Pop() (stack, int) {
107+ l := len(s)
108+ return s[:l-1], s[l-1]
109+}
110+
111+type AttachmentAddedMsg struct {
112+ Attachment message.Attachment
113+}
114+
115+func (f *filepickerCmp) Init() tea.Cmd {
116+ return nil
117+}
118+
119+func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120+ var cmd tea.Cmd
121+ switch msg := msg.(type) {
122+ case tea.WindowSizeMsg:
123+ f.width = 60
124+ f.height = 20
125+ f.viewport.Width = 80
126+ f.viewport.Height = 22
127+ f.cursor = 0
128+ f.getCurrentFileBelowCursor()
129+ case tea.KeyMsg:
130+ switch {
131+ case key.Matches(msg, filePickerKeyMap.InsertCWD):
132+ f.cwd.Focus()
133+ return f, cmd
134+ case key.Matches(msg, filePickerKeyMap.Esc):
135+ if f.cwd.Focused() {
136+ f.cwd.Blur()
137+ }
138+ case key.Matches(msg, filePickerKeyMap.Down):
139+ if !f.cwd.Focused() || msg.String() == downArrow {
140+ if f.cursor < len(f.dirs)-1 {
141+ f.cursor++
142+ f.getCurrentFileBelowCursor()
143+ }
144+ }
145+ case key.Matches(msg, filePickerKeyMap.Up):
146+ if !f.cwd.Focused() || msg.String() == upArrow {
147+ if f.cursor > 0 {
148+ f.cursor--
149+ f.getCurrentFileBelowCursor()
150+ }
151+ }
152+ case key.Matches(msg, filePickerKeyMap.Enter):
153+ var path string
154+ var isPathDir bool
155+ if f.cwd.Focused() {
156+ path = f.cwd.Value()
157+ fileInfo, err := os.Stat(path)
158+ if err != nil {
159+ logging.ErrorPersist("Invalid path")
160+ return f, cmd
161+ }
162+ isPathDir = fileInfo.IsDir()
163+ } else {
164+ path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
165+ isPathDir = f.dirs[f.cursor].IsDir()
166+ }
167+ if isPathDir {
168+ path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
169+ newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
170+ f.cwdDetails.child = &newWorkingDir
171+ f.cwdDetails = f.cwdDetails.child
172+ f.cursorChain = f.cursorChain.Push(f.cursor)
173+ f.dirs = readDir(f.cwdDetails.directory, false)
174+ f.cursor = 0
175+ f.cwd.SetValue(f.cwdDetails.directory)
176+ f.getCurrentFileBelowCursor()
177+ } else {
178+ f.selectedFile = path
179+ return f.addAttachmentToMessage()
180+ }
181+ case key.Matches(msg, filePickerKeyMap.Esc):
182+ if !f.cwd.Focused() {
183+ f.cursorChain = make(stack, 0)
184+ f.cursor = 0
185+ } else {
186+ f.cwd.Blur()
187+ }
188+ case key.Matches(msg, filePickerKeyMap.Forward):
189+ if !f.cwd.Focused() {
190+ if f.dirs[f.cursor].IsDir() {
191+ path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
192+ newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
193+ f.cwdDetails.child = &newWorkingDir
194+ f.cwdDetails = f.cwdDetails.child
195+ f.cursorChain = f.cursorChain.Push(f.cursor)
196+ f.dirs = readDir(f.cwdDetails.directory, false)
197+ f.cursor = 0
198+ f.cwd.SetValue(f.cwdDetails.directory)
199+ f.getCurrentFileBelowCursor()
200+ }
201+ }
202+ case key.Matches(msg, filePickerKeyMap.Backward):
203+ if !f.cwd.Focused() {
204+ if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
205+ f.cursorChain, f.cursor = f.cursorChain.Pop()
206+ f.cwdDetails = f.cwdDetails.parent
207+ f.cwdDetails.child = nil
208+ f.dirs = readDir(f.cwdDetails.directory, false)
209+ f.cwd.SetValue(f.cwdDetails.directory)
210+ f.getCurrentFileBelowCursor()
211+ }
212+ }
213+ case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
214+ f.dirs = readDir(f.cwdDetails.directory, false)
215+ f.cursor = 0
216+ f.getCurrentFileBelowCursor()
217+ }
218+ }
219+ if f.cwd.Focused() {
220+ f.cwd, cmd = f.cwd.Update(msg)
221+ }
222+ return f, cmd
223+}
224+
225+func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
226+ modeInfo := GetSelectedModel(config.Get())
227+ if !modeInfo.SupportsAttachments {
228+ logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
229+ return f, nil
230+ }
231+ if isExtSupported(f.dirs[f.cursor].Name()) {
232+ f.selectedFile = f.dirs[f.cursor].Name()
233+ selectedFilePath := filepath.Join(f.cwdDetails.directory, "/", f.selectedFile)
234+ isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
235+ if err != nil {
236+ logging.ErrorPersist("unable to read the image")
237+ return f, nil
238+ }
239+ if isFileLarge {
240+ logging.ErrorPersist("file too large, max 5MB")
241+ return f, nil
242+ }
243+
244+ content, err := os.ReadFile(selectedFilePath)
245+ if err != nil {
246+ logging.ErrorPersist("Unable read selected file")
247+ return f, nil
248+ }
249+
250+ mimeBufferSize := min(512, len(content))
251+ mimeType := http.DetectContentType(content[:mimeBufferSize])
252+ fileName := f.selectedFile
253+ attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
254+ f.selectedFile = ""
255+ return f, util.CmdHandler(AttachmentAddedMsg{attachment})
256+ }
257+ if !isExtSupported(f.selectedFile) {
258+ logging.ErrorPersist("Unsupported file")
259+ return f, nil
260+ }
261+ return f, nil
262+}
263+
264+func (f *filepickerCmp) View() string {
265+ t := theme.CurrentTheme()
266+ const maxVisibleDirs = 20
267+ const maxWidth = 80
268+
269+ adjustedWidth := maxWidth
270+ for _, file := range f.dirs {
271+ if len(file.Name()) > adjustedWidth-4 { // Account for padding
272+ adjustedWidth = len(file.Name()) + 4
273+ }
274+ }
275+ adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
276+
277+ files := make([]string, 0, maxVisibleDirs)
278+ startIdx := 0
279+
280+ if len(f.dirs) > maxVisibleDirs {
281+ halfVisible := maxVisibleDirs / 2
282+ if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
283+ startIdx = f.cursor - halfVisible
284+ } else if f.cursor >= len(f.dirs)-halfVisible {
285+ startIdx = len(f.dirs) - maxVisibleDirs
286+ }
287+ }
288+
289+ endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
290+
291+ for i := startIdx; i < endIdx; i++ {
292+ file := f.dirs[i]
293+ itemStyle := styles.BaseStyle().Width(adjustedWidth)
294+
295+ if i == f.cursor {
296+ itemStyle = itemStyle.
297+ Background(t.Primary()).
298+ Foreground(t.Background()).
299+ Bold(true)
300+ }
301+ filename := file.Name()
302+
303+ if len(filename) > adjustedWidth-4 {
304+ filename = filename[:adjustedWidth-7] + "..."
305+ }
306+ if file.IsDir() {
307+ filename = filename + "/"
308+ } else if isExtSupported(file.Name()) {
309+ filename = filename
310+ } else {
311+ filename = filename
312+ }
313+
314+ files = append(files, itemStyle.Padding(0, 1).Render(filename))
315+ }
316+
317+ // Pad to always show exactly 21 lines
318+ for len(files) < maxVisibleDirs {
319+ files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
320+ }
321+
322+ currentPath := styles.BaseStyle().
323+ Height(1).
324+ Width(adjustedWidth).
325+ Render(f.cwd.View())
326+
327+ viewportstyle := lipgloss.NewStyle().
328+ Width(f.viewport.Width).
329+ Background(t.Background()).
330+ Border(lipgloss.RoundedBorder()).
331+ BorderForeground(t.TextMuted()).
332+ BorderBackground(t.Background()).
333+ Padding(2).
334+ Render(f.viewport.View())
335+ var insertExitText string
336+ if f.IsCWDFocused() {
337+ insertExitText = "Press esc to exit typing path"
338+ } else {
339+ insertExitText = "Press i to start typing path"
340+ }
341+
342+ content := lipgloss.JoinVertical(
343+ lipgloss.Left,
344+ currentPath,
345+ styles.BaseStyle().Width(adjustedWidth).Render(""),
346+ styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
347+ styles.BaseStyle().Width(adjustedWidth).Render(""),
348+ styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
349+ )
350+
351+ f.cwd.SetValue(f.cwd.Value())
352+ contentStyle := styles.BaseStyle().Padding(1, 2).
353+ Border(lipgloss.RoundedBorder()).
354+ BorderBackground(t.Background()).
355+ BorderForeground(t.TextMuted()).
356+ Width(lipgloss.Width(content) + 4)
357+
358+ return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
359+}
360+
361+type FilepickerCmp interface {
362+ tea.Model
363+ ToggleFilepicker(showFilepicker bool)
364+ IsCWDFocused() bool
365+}
366+
367+func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
368+ f.ShowFilePicker = showFilepicker
369+}
370+
371+func (f *filepickerCmp) IsCWDFocused() bool {
372+ return f.cwd.Focused()
373+}
374+
375+func NewFilepickerCmp(app *app.App) FilepickerCmp {
376+ homepath, err := os.UserHomeDir()
377+ if err != nil {
378+ logging.Error("error loading user files")
379+ return nil
380+ }
381+ baseDir := DirNode{parent: nil, directory: homepath}
382+ dirs := readDir(homepath, false)
383+ viewport := viewport.New(0, 0)
384+ currentDirectory := textinput.New()
385+ currentDirectory.CharLimit = 200
386+ currentDirectory.Width = 44
387+ currentDirectory.Cursor.Blink = true
388+ currentDirectory.SetValue(baseDir.directory)
389+ return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
390+}
391+
392+func (f *filepickerCmp) getCurrentFileBelowCursor() {
393+ if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
394+ logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
395+ f.viewport.SetContent("Preview unavailable")
396+ return
397+ }
398+
399+ dir := f.dirs[f.cursor]
400+ filename := dir.Name()
401+ if !dir.IsDir() && isExtSupported(filename) {
402+ fullPath := f.cwdDetails.directory + "/" + dir.Name()
403+
404+ go func() {
405+ imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
406+ if err != nil {
407+ logging.Error(err.Error())
408+ f.viewport.SetContent("Preview unavailable")
409+ return
410+ }
411+
412+ f.viewport.SetContent(imageString)
413+ }()
414+ } else {
415+ f.viewport.SetContent("Preview unavailable")
416+ }
417+}
418+
419+func readDir(path string, showHidden bool) []os.DirEntry {
420+ logging.Info(fmt.Sprintf("Reading directory: %s", path))
421+
422+ entriesChan := make(chan []os.DirEntry, 1)
423+ errChan := make(chan error, 1)
424+
425+ go func() {
426+ dirEntries, err := os.ReadDir(path)
427+ if err != nil {
428+ logging.ErrorPersist(err.Error())
429+ errChan <- err
430+ return
431+ }
432+ entriesChan <- dirEntries
433+ }()
434+
435+ select {
436+ case dirEntries := <-entriesChan:
437+ sort.Slice(dirEntries, func(i, j int) bool {
438+ if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
439+ return dirEntries[i].Name() < dirEntries[j].Name()
440+ }
441+ return dirEntries[i].IsDir()
442+ })
443+
444+ if showHidden {
445+ return dirEntries
446+ }
447+
448+ var sanitizedDirEntries []os.DirEntry
449+ for _, dirEntry := range dirEntries {
450+ isHidden, _ := IsHidden(dirEntry.Name())
451+ if !isHidden {
452+ if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
453+ sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
454+ }
455+ }
456+ }
457+
458+ return sanitizedDirEntries
459+
460+ case err := <-errChan:
461+ logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
462+ return []os.DirEntry{}
463+
464+ case <-time.After(5 * time.Second):
465+ logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
466+ return []os.DirEntry{}
467+ }
468+}
469+
470+func IsHidden(file string) (bool, error) {
471+ return strings.HasPrefix(file, "."), nil
472+}
473+
474+func isExtSupported(path string) bool {
475+ ext := strings.ToLower(filepath.Ext(path))
476+ return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
477+}
internal/tui/components/dialog/help.go+1−1
@@ -74,7 +74,7 @@ func (h *helpCmp) render() string {
74 var (
74 var (
75 pairs []string
75 pairs []string
76 width int
76 width int
77− rows = 10 - 2
77+ rows = 12 - 2
78 )
78 )
79
79
80 for i := 0; i < len(bindings); i += rows {
80 for i := 0; i < len(bindings); i += rows {
internal/tui/components/dialog/models.go+8−5
@@ -270,20 +270,23 @@ func (m *modelDialogCmp) BindingKeys() []key.Binding {
270
270
271func (m *modelDialogCmp) setupModels() {
271func (m *modelDialogCmp) setupModels() {
272 cfg := config.Get()
272 cfg := config.Get()
273−
273+ modelInfo := GetSelectedModel(cfg)
274 m.availableProviders = getEnabledProviders(cfg)
274 m.availableProviders = getEnabledProviders(cfg)
275 m.hScrollPossible = len(m.availableProviders) > 1
275 m.hScrollPossible = len(m.availableProviders) > 1
276
276
277− agentCfg := cfg.Agents[config.AgentCoder]
278− selectedModelId := agentCfg.Model
279− modelInfo := models.SupportedModels[selectedModelId]
280−
281 m.provider = modelInfo.Provider
277 m.provider = modelInfo.Provider
282 m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
278 m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
283
279
284 m.setupModelsForProvider(m.provider)
280 m.setupModelsForProvider(m.provider)
285}
281}
286
282
283+func GetSelectedModel(cfg *config.Config) models.Model {
284+
285+ agentCfg := cfg.Agents[config.AgentCoder]
286+ selectedModelId := agentCfg.Model
287+ return models.SupportedModels[selectedModelId]
288+}
289+
287func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
290func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
288 var providers []models.ModelProvider
291 var providers []models.ModelProvider
289 for providerId, provider := range cfg.Providers {
292 for providerId, provider := range cfg.Providers {
internal/tui/components/dialog/permission.go+1−2
@@ -2,8 +2,6 @@ package dialog
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "strings"
6−
7 "github.com/charmbracelet/bubbles/key"
5 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/viewport"
6 "github.com/charmbracelet/bubbles/viewport"
9 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
@@ -15,6 +13,7 @@ import (
15 "github.com/opencode-ai/opencode/internal/tui/styles"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
16 "github.com/opencode-ai/opencode/internal/tui/theme"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
17 "github.com/opencode-ai/opencode/internal/tui/util"
15 "github.com/opencode-ai/opencode/internal/tui/util"
16+ "strings"
18)
17)
19
18
20type PermissionAction string
19type PermissionAction string
internal/tui/image/images.gonew file+72
@@ -0,0 +1,72 @@
1+package image
2+
3+import (
4+ "fmt"
5+ "image"
6+ "os"
7+ "strings"
8+
9+ "github.com/charmbracelet/lipgloss"
10+ "github.com/disintegration/imaging"
11+ "github.com/lucasb-eyer/go-colorful"
12+)
13+
14+func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
15+ fileInfo, err := os.Stat(filePath)
16+ if err != nil {
17+ return false, fmt.Errorf("error getting file info: %w", err)
18+ }
19+
20+ if fileInfo.Size() > sizeLimit {
21+ return true, nil
22+ }
23+
24+ return false, nil
25+}
26+
27+func ToString(width int, img image.Image) string {
28+ img = imaging.Resize(img, width, 0, imaging.Lanczos)
29+ b := img.Bounds()
30+ imageWidth := b.Max.X
31+ h := b.Max.Y
32+ str := strings.Builder{}
33+
34+ for heightCounter := 0; heightCounter < h; heightCounter += 2 {
35+ for x := range imageWidth {
36+ c1, _ := colorful.MakeColor(img.At(x, heightCounter))
37+ color1 := lipgloss.Color(c1.Hex())
38+
39+ var color2 lipgloss.Color
40+ if heightCounter+1 < h {
41+ c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
42+ color2 = lipgloss.Color(c2.Hex())
43+ } else {
44+ color2 = color1
45+ }
46+
47+ str.WriteString(lipgloss.NewStyle().Foreground(color1).
48+ Background(color2).Render("▀"))
49+ }
50+
51+ str.WriteString("\n")
52+ }
53+
54+ return str.String()
55+}
56+
57+func ImagePreview(width int, filename string) (string, error) {
58+ imageContent, err := os.Open(filename)
59+ if err != nil {
60+ return "", err
61+ }
62+ defer imageContent.Close()
63+
64+ img, _, err := image.Decode(imageContent)
65+ if err != nil {
66+ return "", err
67+ }
68+
69+ imageString := ToString(width, img)
70+
71+ return imageString, nil
72+}
internal/tui/page/chat.go+8−3
@@ -6,6 +6,7 @@ import (
6 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
7 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/opencode-ai/opencode/internal/app"
8 "github.com/opencode-ai/opencode/internal/app"
9+ "github.com/opencode-ai/opencode/internal/message"
9 "github.com/opencode-ai/opencode/internal/session"
10 "github.com/opencode-ai/opencode/internal/session"
10 "github.com/opencode-ai/opencode/internal/tui/components/chat"
11 "github.com/opencode-ai/opencode/internal/tui/components/chat"
11 "github.com/opencode-ai/opencode/internal/tui/layout"
12 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -52,7 +53,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
52 cmd := p.layout.SetSize(msg.Width, msg.Height)
53 cmd := p.layout.SetSize(msg.Width, msg.Height)
53 cmds = append(cmds, cmd)
54 cmds = append(cmds, cmd)
54 case chat.SendMsg:
55 case chat.SendMsg:
55− cmd := p.sendMessage(msg.Text)
56+ cmd := p.sendMessage(msg.Text, msg.Attachments)
56 if cmd != nil {
57 if cmd != nil {
57 return p, cmd
58 return p, cmd
58 }
59 }
@@ -99,7 +100,7 @@ func (p *chatPage) clearSidebar() tea.Cmd {
99 return p.layout.ClearRightPanel()
100 return p.layout.ClearRightPanel()
100}
101}
101
102
102−func (p *chatPage) sendMessage(text string) tea.Cmd {
103+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
103 var cmds []tea.Cmd
104 var cmds []tea.Cmd
104 if p.session.ID == "" {
105 if p.session.ID == "" {
105 session, err := p.app.Sessions.Create(context.Background(), "New Session")
106 session, err := p.app.Sessions.Create(context.Background(), "New Session")
@@ -115,7 +116,10 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
115 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
116 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
116 }
117 }
117
118
118− p.app.CoderAgent.Run(context.Background(), p.session.ID, text)
119+ _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
120+ if err != nil {
121+ return util.ReportError(err)
122+ }
119 return tea.Batch(cmds...)
123 return tea.Batch(cmds...)
120}
124}
121
125
@@ -134,6 +138,7 @@ func (p *chatPage) View() string {
134func (p *chatPage) BindingKeys() []key.Binding {
138func (p *chatPage) BindingKeys() []key.Binding {
135 bindings := layout.KeyMapToSlice(keyMap)
139 bindings := layout.KeyMapToSlice(keyMap)
136 bindings = append(bindings, p.messages.BindingKeys()...)
140 bindings = append(bindings, p.messages.BindingKeys()...)
141+ bindings = append(bindings, p.editor.BindingKeys()...)
137 return bindings
142 return bindings
138}
143}
139
144
internal/tui/styles/icons.go+9−8
@@ -3,11 +3,12 @@ package styles
3const (
3const (
4 OpenCodeIcon string = "⌬"
4 OpenCodeIcon string = "⌬"
5
5
6− CheckIcon string = "✓"
6+ CheckIcon string = "✓"
7− ErrorIcon string = "✖"
7+ ErrorIcon string = "✖"
8− WarningIcon string = "⚠"
8+ WarningIcon string = "⚠"
9− InfoIcon string = ""
9+ InfoIcon string = ""
10− HintIcon string = "i"
10+ HintIcon string = "i"
11− SpinnerIcon string = "..."
11+ SpinnerIcon string = "..."
12− LoadingIcon string = "⟳"
12+ LoadingIcon string = "⟳"
13+ DocumentIcon string = "🖼"
13−)
14+)
internal/tui/styles/styles.go+4−1
@@ -5,6 +5,10 @@ import (
5 "github.com/opencode-ai/opencode/internal/tui/theme"
5 "github.com/opencode-ai/opencode/internal/tui/theme"
6)
6)
7
7
8+var (
9+ ImageBakcground = "#212121"
10+)
11+
8// Style generation functions that use the current theme
12// Style generation functions that use the current theme
9
13
10// BaseStyle returns the base style with background and foreground colors
14// BaseStyle returns the base style with background and foreground colors
@@ -149,4 +153,3 @@ func BorderFocusedColor() lipgloss.AdaptiveColor {
149func BorderDimColor() lipgloss.AdaptiveColor {
153func BorderDimColor() lipgloss.AdaptiveColor {
150 return theme.CurrentTheme().BorderDim()
154 return theme.CurrentTheme().BorderDim()
151}
155}
152−
internal/tui/tui.go+89−22
@@ -26,10 +26,15 @@ type keyMap struct {
26 Help key.Binding
26 Help key.Binding
27 SwitchSession key.Binding
27 SwitchSession key.Binding
28 Commands key.Binding
28 Commands key.Binding
29+ Filepicker key.Binding
29 Models key.Binding
30 Models key.Binding
30 SwitchTheme key.Binding
31 SwitchTheme key.Binding
31}
32}
32
33
34+const (
35+ quitKey = "q"
36+)
37+
33var keys = keyMap{
38var keys = keyMap{
34 Logs: key.NewBinding(
39 Logs: key.NewBinding(
35 key.WithKeys("ctrl+l"),
40 key.WithKeys("ctrl+l"),
@@ -54,7 +59,10 @@ var keys = keyMap{
54 key.WithKeys("ctrl+k"),
59 key.WithKeys("ctrl+k"),
55 key.WithHelp("ctrl+k", "commands"),
60 key.WithHelp("ctrl+k", "commands"),
56 ),
61 ),
57−
62+ Filepicker: key.NewBinding(
63+ key.WithKeys("ctrl+f"),
64+ key.WithHelp("ctrl+f", "select files to upload"),
65+ ),
58 Models: key.NewBinding(
66 Models: key.NewBinding(
59 key.WithKeys("ctrl+o"),
67 key.WithKeys("ctrl+o"),
60 key.WithHelp("ctrl+o", "model selection"),
68 key.WithHelp("ctrl+o", "model selection"),
@@ -77,7 +85,7 @@ var returnKey = key.NewBinding(
77)
85)
78
86
79var logsKeyReturnKey = key.NewBinding(
87var logsKeyReturnKey = key.NewBinding(
80− key.WithKeys("esc", "backspace", "q"),
88+ key.WithKeys("esc", "backspace", quitKey),
81 key.WithHelp("esc/q", "go back"),
89 key.WithHelp("esc/q", "go back"),
82)
90)
83
91
@@ -112,6 +120,9 @@ type appModel struct {
112 showInitDialog bool
120 showInitDialog bool
113 initDialog dialog.InitDialogCmp
121 initDialog dialog.InitDialogCmp
114
122
123+ showFilepicker bool
124+ filepicker dialog.FilepickerCmp
125+
115 showThemeDialog bool
126 showThemeDialog bool
116 themeDialog dialog.ThemeDialog
127 themeDialog dialog.ThemeDialog
117}
128}
@@ -135,6 +146,7 @@ func (a appModel) Init() tea.Cmd {
135 cmds = append(cmds, cmd)
146 cmds = append(cmds, cmd)
136 cmd = a.initDialog.Init()
147 cmd = a.initDialog.Init()
137 cmds = append(cmds, cmd)
148 cmds = append(cmds, cmd)
149+ cmd = a.filepicker.Init()
138 cmd = a.themeDialog.Init()
150 cmd = a.themeDialog.Init()
139 cmds = append(cmds, cmd)
151 cmds = append(cmds, cmd)
140
152
@@ -182,6 +194,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182 a.commandDialog = command.(dialog.CommandDialog)
194 a.commandDialog = command.(dialog.CommandDialog)
183 cmds = append(cmds, commandCmd)
195 cmds = append(cmds, commandCmd)
184
196
197+ filepicker, filepickerCmd := a.filepicker.Update(msg)
198+ a.filepicker = filepicker.(dialog.FilepickerCmp)
199+ cmds = append(cmds, filepickerCmd)
200+
185 a.initDialog.SetSize(msg.Width, msg.Height)
201 a.initDialog.SetSize(msg.Width, msg.Height)
186
202
187 return a, tea.Batch(cmds...)
203 return a, tea.Batch(cmds...)
@@ -333,6 +349,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333
349
334 case tea.KeyMsg:
350 case tea.KeyMsg:
335 switch {
351 switch {
352+
336 case key.Matches(msg, keys.Quit):
353 case key.Matches(msg, keys.Quit):
337 a.showQuit = !a.showQuit
354 a.showQuit = !a.showQuit
338 if a.showHelp {
355 if a.showHelp {
@@ -344,6 +361,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
344 if a.showCommandDialog {
361 if a.showCommandDialog {
345 a.showCommandDialog = false
362 a.showCommandDialog = false
346 }
363 }
364+ if a.showFilepicker {
365+ a.showFilepicker = false
366+ a.filepicker.ToggleFilepicker(a.showFilepicker)
367+ }
347 if a.showModelDialog {
368 if a.showModelDialog {
348 a.showModelDialog = false
369 a.showModelDialog = false
349 }
370 }
@@ -364,7 +385,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
364 }
385 }
365 return a, nil
386 return a, nil
366 case key.Matches(msg, keys.Commands):
387 case key.Matches(msg, keys.Commands):
367− if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog {
388+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
368 // Show commands dialog
389 // Show commands dialog
369 if len(a.commands) == 0 {
390 if len(a.commands) == 0 {
370 return a, util.ReportWarn("No commands available")
391 return a, util.ReportWarn("No commands available")
@@ -392,26 +413,36 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
392 return a, a.themeDialog.Init()
413 return a, a.themeDialog.Init()
393 }
414 }
394 return a, nil
415 return a, nil
395− case key.Matches(msg, logsKeyReturnKey):
416+ case key.Matches(msg, returnKey) || key.Matches(msg):
396− if a.currentPage == page.LogsPage {
417+ if msg.String() == quitKey {
397− return a, a.moveToPage(page.ChatPage)
418+ if a.currentPage == page.LogsPage {
398− }
419+ return a, a.moveToPage(page.ChatPage)
399− case key.Matches(msg, returnKey):
420+ }
400− if a.showQuit {
421+ } else if !a.filepicker.IsCWDFocused() {
401− a.showQuit = !a.showQuit
422+ if a.showQuit {
423+ a.showQuit = !a.showQuit
402− return a, nil
424+ return a, nil
403− }
425+ }
404− if a.showHelp {
426+ if a.showHelp {
405− a.showHelp = !a.showHelp
427+ a.showHelp = !a.showHelp
406− return a, nil
428+ return a, nil
407− }
429+ }
408− if a.showInitDialog {
430+ if a.showInitDialog {
409− a.showInitDialog = false
431+ a.showInitDialog = false
410− // Mark the project as initialized without running the command
432+ // Mark the project as initialized without running the command
411− if err := config.MarkProjectInitialized(); err != nil {
433+ if err := config.MarkProjectInitialized(); err != nil {
412− return a, util.ReportError(err)
434+ return a, util.ReportError(err)
435+ }
436+ return a, nil
437+ }
438+ if a.showFilepicker {
439+ a.showFilepicker = false
440+ a.filepicker.ToggleFilepicker(a.showFilepicker)
441+ return a, nil
442+ }
443+ if a.currentPage == page.LogsPage {
444+ return a, a.moveToPage(page.ChatPage)
413 }
445 }
414− return a, nil
415 }
446 }
416 case key.Matches(msg, keys.Logs):
447 case key.Matches(msg, keys.Logs):
417 return a, a.moveToPage(page.LogsPage)
448 return a, a.moveToPage(page.LogsPage)
@@ -429,10 +460,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
429 a.showHelp = !a.showHelp
460 a.showHelp = !a.showHelp
430 return a, nil
461 return a, nil
431 }
462 }
463+ case key.Matches(msg, keys.Filepicker):
464+ a.showFilepicker = !a.showFilepicker
465+ a.filepicker.ToggleFilepicker(a.showFilepicker)
466+ return a, nil
432 }
467 }
468+ default:
469+ f, filepickerCmd := a.filepicker.Update(msg)
470+ a.filepicker = f.(dialog.FilepickerCmp)
471+ cmds = append(cmds, filepickerCmd)
433
472
434 }
473 }
435
474
475+ if a.showFilepicker {
476+ f, filepickerCmd := a.filepicker.Update(msg)
477+ a.filepicker = f.(dialog.FilepickerCmp)
478+ cmds = append(cmds, filepickerCmd)
479+ // Only block key messages send all other messages down
480+ if _, ok := msg.(tea.KeyMsg); ok {
481+ return a, tea.Batch(cmds...)
482+ }
483+ }
484+
436 if a.showQuit {
485 if a.showQuit {
437 q, quitCmd := a.quit.Update(msg)
486 q, quitCmd := a.quit.Update(msg)
438 a.quit = q.(dialog.QuitDialog)
487 a.quit = q.(dialog.QuitDialog)
@@ -519,6 +568,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
519 // For now we don't move to any page if the agent is busy
568 // For now we don't move to any page if the agent is busy
520 return util.ReportWarn("Agent is busy, please wait...")
569 return util.ReportWarn("Agent is busy, please wait...")
521 }
570 }
571+
522 var cmds []tea.Cmd
572 var cmds []tea.Cmd
523 if _, ok := a.loadedPages[pageID]; !ok {
573 if _, ok := a.loadedPages[pageID]; !ok {
524 cmd := a.pages[pageID].Init()
574 cmd := a.pages[pageID].Init()
@@ -559,6 +609,22 @@ func (a appModel) View() string {
559 )
609 )
560 }
610 }
561
611
612+ if a.showFilepicker {
613+ overlay := a.filepicker.View()
614+ row := lipgloss.Height(appView) / 2
615+ row -= lipgloss.Height(overlay) / 2
616+ col := lipgloss.Width(appView) / 2
617+ col -= lipgloss.Width(overlay) / 2
618+ appView = layout.PlaceOverlay(
619+ col,
620+ row,
621+ overlay,
622+ appView,
623+ true,
624+ )
625+
626+ }
627+
562 if !a.app.CoderAgent.IsBusy() {
628 if !a.app.CoderAgent.IsBusy() {
563 a.status.SetHelpWidgetMsg("ctrl+? help")
629 a.status.SetHelpWidgetMsg("ctrl+? help")
564 } else {
630 } else {
@@ -704,6 +770,7 @@ func New(app *app.App) tea.Model {
704 page.ChatPage: page.NewChatPage(app),
770 page.ChatPage: page.NewChatPage(app),
705 page.LogsPage: page.NewLogsPage(),
771 page.LogsPage: page.NewLogsPage(),
706 },
772 },
773+ filepicker: dialog.NewFilepickerCmp(app),
707 }
774 }
708
775
709 model.RegisterCommand(dialog.Command{
776 model.RegisterCommand(dialog.Command{