on main help sign in
add support for images (#144)
commit9fec8df7d0090ab8ca7bcf0340a1b31fc764fb17 authorphantomreactor <pujari.mahi.mahesh@gmail.com> dateSat 3 May 2025, 01:53 (1 year ago) parent603a3e3
30 files changed+1326−484
go.mod+3−7 go.sum+5−6 internal/llm/agent/agent.go+25−20 internal/llm/models/anthropic.go+56−51 internal/llm/models/azure.go+126−115 internal/llm/models/gemini.go+44−40 internal/llm/models/groq.go+43−38 internal/llm/models/models.go+12−11 internal/llm/models/openai.go+136−124 internal/llm/prompt/title.go+2−1 internal/llm/provider/anthropic.go+13−3 internal/llm/provider/bedrock.go+5−4 internal/llm/provider/gemini.go+7−2 internal/llm/provider/openai.go+12−1 internal/message/attachment.go+8 internal/message/content.go+6−3 internal/message/message.go−1 internal/tui/components/chat/chat.go+3−1 internal/tui/components/chat/editor.go+115−10 internal/tui/components/chat/list.go+13−3 internal/tui/components/chat/message.go+23−1 internal/tui/components/dialog/filepicker.go+477 internal/tui/components/dialog/help.go+1−1 internal/tui/components/dialog/models.go+8−5 internal/tui/components/dialog/permission.go+1−2 internal/tui/image/images.go+72 internal/tui/page/chat.go+8−3 internal/tui/styles/icons.go+9−8 internal/tui/styles/styles.go+4−1 internal/tui/tui.go+89−22
go.mod+3−7
@@ -2,8 +2,6 @@ module github.com/opencode-ai/opencode
22
33go 1.24.0
44
5−toolchain go1.24.2
6−
75require (
86 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
97 github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -16,7 +14,6 @@ require (
1614 github.com/charmbracelet/bubbles v0.20.0
1715 github.com/charmbracelet/bubbletea v1.3.4
1816 github.com/charmbracelet/glamour v0.9.1
19− github.com/charmbracelet/huh v0.6.0
2017 github.com/charmbracelet/lipgloss v1.1.0
2118 github.com/charmbracelet/x/ansi v0.8.0
2219 github.com/fsnotify/fsnotify v1.8.0
@@ -68,11 +65,10 @@ require (
6865 github.com/aymerick/douceur v0.2.0 // indirect
6966 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
7067 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
7268 github.com/charmbracelet/x/term v0.2.1 // indirect
7369 github.com/davecgh/go-spew v1.1.1 // indirect
70+ github.com/disintegration/imaging v1.6.2
7471 github.com/dlclark/regexp2 v1.11.4 // indirect
75− github.com/dustin/go-humanize v1.0.1 // indirect
7672 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
7773 github.com/felixge/httpsnoop v1.0.4 // indirect
7874 github.com/go-logr/logr v1.4.2 // indirect
@@ -85,13 +81,12 @@ require (
8581 github.com/gorilla/css v1.0.1 // indirect
8682 github.com/inconshreveable/mousetrap v1.1.0 // indirect
8783 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
8985 github.com/mattn/go-isatty v0.0.20 // indirect
9086 github.com/mattn/go-localereader v0.0.1 // indirect
9187 github.com/mattn/go-runewidth v0.0.16 // indirect
9288 github.com/mfridman/interpolate v0.0.2 // indirect
9389 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
94− github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
9590 github.com/muesli/cancelreader v0.2.2 // indirect
9691 github.com/ncruces/julianday v1.0.0 // indirect
9792 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
@@ -123,6 +118,7 @@ require (
123118 go.opentelemetry.io/otel/trace v1.35.0 // indirect
124119 go.uber.org/multierr v1.11.0 // indirect
125120 golang.org/x/crypto v0.37.0 // indirect
121+ golang.org/x/image v0.26.0 // indirect
126122 golang.org/x/net v0.39.0 // indirect
127123 golang.org/x/oauth2 v0.25.0 // indirect
128124 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
8282github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
8383github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
8484github.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=
8785github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
8886github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
8987github.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
9290github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
9391github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
9492github.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=
9793github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
9894github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
9995github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
10096github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10197github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10298github.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=
103101github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
104102github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
105103github.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
169167github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
170168github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
171169github.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=
174170github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
175171github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
176172github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -283,6 +279,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
283279golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
284280golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
285281golang.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=
286285golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
287286golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
288287golang.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 {
3838}
3939
4040type 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)
4242 Cancel(sessionID string)
4343 IsSessionBusy(sessionID string) bool
4444 IsBusy() bool
@@ -117,6 +117,9 @@ func (a *agent) IsSessionBusy(sessionID string) bool {
117117}
118118
119119func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
120+ if content == "" {
121+ return nil
122+ }
120123 if a.titleProvider == nil {
121124 return nil
122125 }
@@ -124,16 +127,13 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
124127 if err != nil {
125128 return err
126129 }
130+ parts := []message.ContentPart{message.TextContent{Text: content}}
127131 response, err := a.titleProvider.SendMessages(
128132 ctx,
129133 []message.Message{
130134 {
131− Role: message.User,
132− Parts: []message.ContentPart{
133− message.TextContent{
134− Text: content,
135− },
136− },
135+ Role: message.User,
136+ Parts: parts,
137137 },
138138 },
139139 make([]tools.BaseTool, 0),
@@ -158,7 +158,10 @@ func (a *agent) err(err error) AgentEvent {
158158 }
159159}
160160
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+ }
162165 events := make(chan AgentEvent)
163166 if a.IsSessionBusy(sessionID) {
164167 return nil, ErrSessionBusy
@@ -172,10 +175,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch
172175 defer logging.RecoverPanic("agent.Run", func() {
173176 events <- a.err(fmt.Errorf("panic while running the agent"))
174177 })
175−
176− result := a.processGeneration(genCtx, sessionID, content)
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+ }
182+ result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
177183 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())
179185 }
180186 logging.Debug("Request completed", "sessionID", sessionID)
181187 a.activeRequests.Delete(sessionID)
@@ -186,7 +192,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch
186192 return events, nil
187193}
188194
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 {
190196 // List existing messages; if none, start title generation asynchronously.
191197 msgs, err := a.messages.List(ctx, sessionID)
192198 if err != nil {
@@ -204,13 +210,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
204210 }()
205211 }
206212
207− userMsg, err := a.createUserMessage(ctx, sessionID, content)
213+ userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
208214 if err != nil {
209215 return a.err(fmt.Errorf("failed to create user message: %w", err))
210216 }
211−
212217 // Append the new user message to the conversation history.
213218 msgHistory := append(msgs, userMsg)
219+
214220 for {
215221 // Check for cancellation before each iteration
216222 select {
@@ -240,12 +246,12 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
240246 }
241247}
242248
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...)
244252 return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
245− Role: message.User,
246− Parts: []message.ContentPart{
247− message.TextContent{Text: content},
248− },
253+ Role: message.User,
254+ Parts: parts,
249255 })
250256}
251257
@@ -310,7 +316,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
310316 }
311317 continue
312318 }
313−
314319 toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
315320 ID: toolCall.ID,
316321 Name: toolCall.Name,
internal/llm/models/anthropic.go+56−51
@@ -14,64 +14,69 @@ const (
1414// https://docs.anthropic.com/en/docs/about-claude/models/all-models
1515var AnthropicModels = map[ModelID]Model{
1616 Claude35Sonnet: {
17− ID: Claude35Sonnet,
18− Name: "Claude 3.5 Sonnet",
19− Provider: ProviderAnthropic,
20− APIModel: "claude-3-5-sonnet-latest",
21− CostPer1MIn: 3.0,
22− CostPer1MInCached: 3.75,
23− CostPer1MOutCached: 0.30,
24− CostPer1MOut: 15.0,
25− ContextWindow: 200000,
26− DefaultMaxTokens: 5000,
17+ ID: Claude35Sonnet,
18+ Name: "Claude 3.5 Sonnet",
19+ Provider: ProviderAnthropic,
20+ APIModel: "claude-3-5-sonnet-latest",
21+ CostPer1MIn: 3.0,
22+ CostPer1MInCached: 3.75,
23+ CostPer1MOutCached: 0.30,
24+ CostPer1MOut: 15.0,
25+ ContextWindow: 200000,
26+ DefaultMaxTokens: 5000,
27+ SupportsAttachments: true,
2728 },
2829 Claude3Haiku: {
29− ID: Claude3Haiku,
30− Name: "Claude 3 Haiku",
31− Provider: ProviderAnthropic,
32− APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
33− CostPer1MIn: 0.25,
34− CostPer1MInCached: 0.30,
35− CostPer1MOutCached: 0.03,
36− CostPer1MOut: 1.25,
37− ContextWindow: 200000,
38− DefaultMaxTokens: 4096,
30+ ID: Claude3Haiku,
31+ Name: "Claude 3 Haiku",
32+ Provider: ProviderAnthropic,
33+ APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
34+ CostPer1MIn: 0.25,
35+ CostPer1MInCached: 0.30,
36+ CostPer1MOutCached: 0.03,
37+ CostPer1MOut: 1.25,
38+ ContextWindow: 200000,
39+ DefaultMaxTokens: 4096,
40+ SupportsAttachments: true,
3941 },
4042 Claude37Sonnet: {
41− ID: Claude37Sonnet,
42− Name: "Claude 3.7 Sonnet",
43− Provider: ProviderAnthropic,
44− APIModel: "claude-3-7-sonnet-latest",
45− CostPer1MIn: 3.0,
46− CostPer1MInCached: 3.75,
47− CostPer1MOutCached: 0.30,
48− CostPer1MOut: 15.0,
49− ContextWindow: 200000,
50− DefaultMaxTokens: 50000,
51− CanReason: true,
43+ ID: Claude37Sonnet,
44+ Name: "Claude 3.7 Sonnet",
45+ Provider: ProviderAnthropic,
46+ APIModel: "claude-3-7-sonnet-latest",
47+ CostPer1MIn: 3.0,
48+ CostPer1MInCached: 3.75,
49+ CostPer1MOutCached: 0.30,
50+ CostPer1MOut: 15.0,
51+ ContextWindow: 200000,
52+ DefaultMaxTokens: 50000,
53+ CanReason: true,
54+ SupportsAttachments: true,
5255 },
5356 Claude35Haiku: {
54− ID: Claude35Haiku,
55− Name: "Claude 3.5 Haiku",
56− Provider: ProviderAnthropic,
57− APIModel: "claude-3-5-haiku-latest",
58− CostPer1MIn: 0.80,
59− CostPer1MInCached: 1.0,
60− CostPer1MOutCached: 0.08,
61− CostPer1MOut: 4.0,
62− ContextWindow: 200000,
63− DefaultMaxTokens: 4096,
57+ ID: Claude35Haiku,
58+ Name: "Claude 3.5 Haiku",
59+ Provider: ProviderAnthropic,
60+ APIModel: "claude-3-5-haiku-latest",
61+ CostPer1MIn: 0.80,
62+ CostPer1MInCached: 1.0,
63+ CostPer1MOutCached: 0.08,
64+ CostPer1MOut: 4.0,
65+ ContextWindow: 200000,
66+ DefaultMaxTokens: 4096,
67+ SupportsAttachments: true,
6468 },
6569 Claude3Opus: {
66− ID: Claude3Opus,
67− Name: "Claude 3 Opus",
68− Provider: ProviderAnthropic,
69− APIModel: "claude-3-opus-latest",
70− CostPer1MIn: 15.0,
71− CostPer1MInCached: 18.75,
72− CostPer1MOutCached: 1.50,
73− CostPer1MOut: 75.0,
74− ContextWindow: 200000,
75− DefaultMaxTokens: 4096,
70+ ID: Claude3Opus,
71+ Name: "Claude 3 Opus",
72+ Provider: ProviderAnthropic,
73+ APIModel: "claude-3-opus-latest",
74+ CostPer1MIn: 15.0,
75+ CostPer1MInCached: 18.75,
76+ CostPer1MOutCached: 1.50,
77+ CostPer1MOut: 75.0,
78+ ContextWindow: 200000,
79+ DefaultMaxTokens: 4096,
80+ SupportsAttachments: true,
7681 },
7782}
internal/llm/models/azure.go+126−115
@@ -18,140 +18,151 @@ const (
1818
1919var AzureModels = map[ModelID]Model{
2020 AzureGPT41: {
21− ID: AzureGPT41,
22− Name: "Azure OpenAI – GPT 4.1",
23− Provider: ProviderAzure,
24− APIModel: "gpt-4.1",
25− CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
26− CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
27− CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
28− CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
29− ContextWindow: OpenAIModels[GPT41].ContextWindow,
30− DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
21+ ID: AzureGPT41,
22+ Name: "Azure OpenAI – GPT 4.1",
23+ Provider: ProviderAzure,
24+ APIModel: "gpt-4.1",
25+ CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
26+ CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
27+ CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
28+ CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
29+ ContextWindow: OpenAIModels[GPT41].ContextWindow,
30+ DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
31+ SupportsAttachments: true,
3132 },
3233 AzureGPT41Mini: {
33− ID: AzureGPT41Mini,
34− Name: "Azure OpenAI – GPT 4.1 mini",
35− Provider: ProviderAzure,
36− APIModel: "gpt-4.1-mini",
37− CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
38− CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
39− CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
40− CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
41− ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
42− DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
34+ ID: AzureGPT41Mini,
35+ Name: "Azure OpenAI – GPT 4.1 mini",
36+ Provider: ProviderAzure,
37+ APIModel: "gpt-4.1-mini",
38+ CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
39+ CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
40+ CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
41+ CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
42+ ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
43+ DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
44+ SupportsAttachments: true,
4345 },
4446 AzureGPT41Nano: {
45− ID: AzureGPT41Nano,
46− Name: "Azure OpenAI – GPT 4.1 nano",
47− Provider: ProviderAzure,
48− APIModel: "gpt-4.1-nano",
49− CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
50− CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
51− CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
52− CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
53− ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
54− DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
47+ ID: AzureGPT41Nano,
48+ Name: "Azure OpenAI – GPT 4.1 nano",
49+ Provider: ProviderAzure,
50+ APIModel: "gpt-4.1-nano",
51+ CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
52+ CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
53+ CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
54+ CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
55+ ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
56+ DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
57+ SupportsAttachments: true,
5558 },
5659 AzureGPT45Preview: {
57− ID: AzureGPT45Preview,
58− Name: "Azure OpenAI – GPT 4.5 preview",
59− Provider: ProviderAzure,
60− APIModel: "gpt-4.5-preview",
61− CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
62− CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
63− CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
64− CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
65− ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
66− DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
60+ ID: AzureGPT45Preview,
61+ Name: "Azure OpenAI – GPT 4.5 preview",
62+ Provider: ProviderAzure,
63+ APIModel: "gpt-4.5-preview",
64+ CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
65+ CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
66+ CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
67+ CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
68+ ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
69+ DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
70+ SupportsAttachments: true,
6771 },
6872 AzureGPT4o: {
69− ID: AzureGPT4o,
70− Name: "Azure OpenAI – GPT-4o",
71− Provider: ProviderAzure,
72− APIModel: "gpt-4o",
73− CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
74− CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
75− CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
76− CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
77− ContextWindow: OpenAIModels[GPT4o].ContextWindow,
78− DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
73+ ID: AzureGPT4o,
74+ Name: "Azure OpenAI – GPT-4o",
75+ Provider: ProviderAzure,
76+ APIModel: "gpt-4o",
77+ CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
78+ CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
79+ CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
80+ CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
81+ ContextWindow: OpenAIModels[GPT4o].ContextWindow,
82+ DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
83+ SupportsAttachments: true,
7984 },
8085 AzureGPT4oMini: {
81− ID: AzureGPT4oMini,
82− Name: "Azure OpenAI – GPT-4o mini",
83− Provider: ProviderAzure,
84− APIModel: "gpt-4o-mini",
85− CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
86− CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
87− CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
88− CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
89− ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
90− DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
86+ ID: AzureGPT4oMini,
87+ Name: "Azure OpenAI – GPT-4o mini",
88+ Provider: ProviderAzure,
89+ APIModel: "gpt-4o-mini",
90+ CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
91+ CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
92+ CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
93+ CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
94+ ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
95+ DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
96+ SupportsAttachments: true,
9197 },
9298 AzureO1: {
93− ID: AzureO1,
94− Name: "Azure OpenAI – O1",
95− Provider: ProviderAzure,
96− APIModel: "o1",
97− CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
98− CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
99− CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
100− CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
101− ContextWindow: OpenAIModels[O1].ContextWindow,
102− DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
103− CanReason: OpenAIModels[O1].CanReason,
99+ ID: AzureO1,
100+ Name: "Azure OpenAI – O1",
101+ Provider: ProviderAzure,
102+ APIModel: "o1",
103+ CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
104+ CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
105+ CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
106+ CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
107+ ContextWindow: OpenAIModels[O1].ContextWindow,
108+ DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
109+ CanReason: OpenAIModels[O1].CanReason,
110+ SupportsAttachments: true,
104111 },
105112 AzureO1Mini: {
106− ID: AzureO1Mini,
107− Name: "Azure OpenAI – O1 mini",
108− Provider: ProviderAzure,
109− APIModel: "o1-mini",
110− CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
111− CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
112− CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
113− CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
114− ContextWindow: OpenAIModels[O1Mini].ContextWindow,
115− DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
116− CanReason: OpenAIModels[O1Mini].CanReason,
113+ ID: AzureO1Mini,
114+ Name: "Azure OpenAI – O1 mini",
115+ Provider: ProviderAzure,
116+ APIModel: "o1-mini",
117+ CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
118+ CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
119+ CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
120+ CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
121+ ContextWindow: OpenAIModels[O1Mini].ContextWindow,
122+ DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
123+ CanReason: OpenAIModels[O1Mini].CanReason,
124+ SupportsAttachments: true,
117125 },
118126 AzureO3: {
119− ID: AzureO3,
120− Name: "Azure OpenAI – O3",
121− Provider: ProviderAzure,
122− APIModel: "o3",
123− CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
124− CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
125− CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
126− CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
127− ContextWindow: OpenAIModels[O3].ContextWindow,
128− DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
129− CanReason: OpenAIModels[O3].CanReason,
127+ ID: AzureO3,
128+ Name: "Azure OpenAI – O3",
129+ Provider: ProviderAzure,
130+ APIModel: "o3",
131+ CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
132+ CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
133+ CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
134+ CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
135+ ContextWindow: OpenAIModels[O3].ContextWindow,
136+ DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
137+ CanReason: OpenAIModels[O3].CanReason,
138+ SupportsAttachments: true,
130139 },
131140 AzureO3Mini: {
132− ID: AzureO3Mini,
133− Name: "Azure OpenAI – O3 mini",
134− Provider: ProviderAzure,
135− APIModel: "o3-mini",
136− CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
137− CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
138− CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
139− CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
140− ContextWindow: OpenAIModels[O3Mini].ContextWindow,
141− DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
142− CanReason: OpenAIModels[O3Mini].CanReason,
141+ ID: AzureO3Mini,
142+ Name: "Azure OpenAI – O3 mini",
143+ Provider: ProviderAzure,
144+ APIModel: "o3-mini",
145+ CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
146+ CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
147+ CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
148+ CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
149+ ContextWindow: OpenAIModels[O3Mini].ContextWindow,
150+ DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
151+ CanReason: OpenAIModels[O3Mini].CanReason,
152+ SupportsAttachments: false,
143153 },
144154 AzureO4Mini: {
145− ID: AzureO4Mini,
146− Name: "Azure OpenAI – O4 mini",
147− Provider: ProviderAzure,
148− APIModel: "o4-mini",
149− CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
150− CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
151− CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
152− CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
153− ContextWindow: OpenAIModels[O4Mini].ContextWindow,
154− DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
155− CanReason: OpenAIModels[O4Mini].CanReason,
155+ ID: AzureO4Mini,
156+ Name: "Azure OpenAI – O4 mini",
157+ Provider: ProviderAzure,
158+ APIModel: "o4-mini",
159+ CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
160+ CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
161+ CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
162+ CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
163+ ContextWindow: OpenAIModels[O4Mini].ContextWindow,
164+ DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
165+ CanReason: OpenAIModels[O4Mini].CanReason,
166+ SupportsAttachments: true,
156167 },
157168}
internal/llm/models/gemini.go+44−40
@@ -12,52 +12,56 @@ const (
1212
1313var GeminiModels = map[ModelID]Model{
1414 Gemini25Flash: {
15− ID: Gemini25Flash,
16− Name: "Gemini 2.5 Flash",
17− Provider: ProviderGemini,
18− APIModel: "gemini-2.5-flash-preview-04-17",
19− CostPer1MIn: 0.15,
20− CostPer1MInCached: 0,
21− CostPer1MOutCached: 0,
22− CostPer1MOut: 0.60,
23− ContextWindow: 1000000,
24− DefaultMaxTokens: 50000,
15+ ID: Gemini25Flash,
16+ Name: "Gemini 2.5 Flash",
17+ Provider: ProviderGemini,
18+ APIModel: "gemini-2.5-flash-preview-04-17",
19+ CostPer1MIn: 0.15,
20+ CostPer1MInCached: 0,
21+ CostPer1MOutCached: 0,
22+ CostPer1MOut: 0.60,
23+ ContextWindow: 1000000,
24+ DefaultMaxTokens: 50000,
25+ SupportsAttachments: true,
2526 },
2627 Gemini25: {
27− ID: Gemini25,
28− Name: "Gemini 2.5 Pro",
29− Provider: ProviderGemini,
30− APIModel: "gemini-2.5-pro-preview-03-25",
31− CostPer1MIn: 1.25,
32− CostPer1MInCached: 0,
33− CostPer1MOutCached: 0,
34− CostPer1MOut: 10,
35− ContextWindow: 1000000,
36− DefaultMaxTokens: 50000,
28+ ID: Gemini25,
29+ Name: "Gemini 2.5 Pro",
30+ Provider: ProviderGemini,
31+ APIModel: "gemini-2.5-pro-preview-03-25",
32+ CostPer1MIn: 1.25,
33+ CostPer1MInCached: 0,
34+ CostPer1MOutCached: 0,
35+ CostPer1MOut: 10,
36+ ContextWindow: 1000000,
37+ DefaultMaxTokens: 50000,
38+ SupportsAttachments: true,
3739 },
3840
3941 Gemini20Flash: {
40− ID: Gemini20Flash,
41− Name: "Gemini 2.0 Flash",
42− Provider: ProviderGemini,
43− APIModel: "gemini-2.0-flash",
44− CostPer1MIn: 0.10,
45− CostPer1MInCached: 0,
46− CostPer1MOutCached: 0,
47− CostPer1MOut: 0.40,
48− ContextWindow: 1000000,
49− DefaultMaxTokens: 6000,
42+ ID: Gemini20Flash,
43+ Name: "Gemini 2.0 Flash",
44+ Provider: ProviderGemini,
45+ APIModel: "gemini-2.0-flash",
46+ CostPer1MIn: 0.10,
47+ CostPer1MInCached: 0,
48+ CostPer1MOutCached: 0,
49+ CostPer1MOut: 0.40,
50+ ContextWindow: 1000000,
51+ DefaultMaxTokens: 6000,
52+ SupportsAttachments: true,
5053 },
5154 Gemini20FlashLite: {
52− ID: Gemini20FlashLite,
53− Name: "Gemini 2.0 Flash Lite",
54− Provider: ProviderGemini,
55− APIModel: "gemini-2.0-flash-lite",
56− CostPer1MIn: 0.05,
57− CostPer1MInCached: 0,
58− CostPer1MOutCached: 0,
59− CostPer1MOut: 0.30,
60− ContextWindow: 1000000,
61− DefaultMaxTokens: 6000,
55+ ID: Gemini20FlashLite,
56+ Name: "Gemini 2.0 Flash Lite",
57+ Provider: ProviderGemini,
58+ APIModel: "gemini-2.0-flash-lite",
59+ CostPer1MIn: 0.05,
60+ CostPer1MInCached: 0,
61+ CostPer1MOutCached: 0,
62+ CostPer1MOut: 0.30,
63+ ContextWindow: 1000000,
64+ DefaultMaxTokens: 6000,
65+ SupportsAttachments: true,
6266 },
6367}
internal/llm/models/groq.go+43−38
@@ -28,55 +28,60 @@ var GroqModels = map[ModelID]Model{
2828 ContextWindow: 128_000,
2929 DefaultMaxTokens: 50000,
3030 // for some reason, the groq api doesn't like the reasoningEffort parameter
31− CanReason: false,
31+ CanReason: false,
32+ SupportsAttachments: false,
3233 },
3334
3435 Llama4Scout: {
35− ID: Llama4Scout,
36− Name: "Llama4Scout",
37− Provider: ProviderGROQ,
38− APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
39− CostPer1MIn: 0.11,
40− CostPer1MInCached: 0,
41− CostPer1MOutCached: 0,
42− CostPer1MOut: 0.34,
43− ContextWindow: 128_000, // 10M when?
36+ ID: Llama4Scout,
37+ Name: "Llama4Scout",
38+ Provider: ProviderGROQ,
39+ APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
40+ CostPer1MIn: 0.11,
41+ CostPer1MInCached: 0,
42+ CostPer1MOutCached: 0,
43+ CostPer1MOut: 0.34,
44+ ContextWindow: 128_000, // 10M when?
45+ SupportsAttachments: true,
4446 },
4547
4648 Llama4Maverick: {
47− ID: Llama4Maverick,
48− Name: "Llama4Maverick",
49− Provider: ProviderGROQ,
50− APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
51− CostPer1MIn: 0.20,
52− CostPer1MInCached: 0,
53− CostPer1MOutCached: 0,
54− CostPer1MOut: 0.20,
55− ContextWindow: 128_000,
49+ ID: Llama4Maverick,
50+ Name: "Llama4Maverick",
51+ Provider: ProviderGROQ,
52+ APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
53+ CostPer1MIn: 0.20,
54+ CostPer1MInCached: 0,
55+ CostPer1MOutCached: 0,
56+ CostPer1MOut: 0.20,
57+ ContextWindow: 128_000,
58+ SupportsAttachments: true,
5659 },
5760
5861 Llama3_3_70BVersatile: {
59− ID: Llama3_3_70BVersatile,
60− Name: "Llama3_3_70BVersatile",
61− Provider: ProviderGROQ,
62− APIModel: "llama-3.3-70b-versatile",
63− CostPer1MIn: 0.59,
64− CostPer1MInCached: 0,
65− CostPer1MOutCached: 0,
66− CostPer1MOut: 0.79,
67− ContextWindow: 128_000,
62+ ID: Llama3_3_70BVersatile,
63+ Name: "Llama3_3_70BVersatile",
64+ Provider: ProviderGROQ,
65+ APIModel: "llama-3.3-70b-versatile",
66+ CostPer1MIn: 0.59,
67+ CostPer1MInCached: 0,
68+ CostPer1MOutCached: 0,
69+ CostPer1MOut: 0.79,
70+ ContextWindow: 128_000,
71+ SupportsAttachments: false,
6872 },
6973
7074 DeepseekR1DistillLlama70b: {
71− ID: DeepseekR1DistillLlama70b,
72− Name: "DeepseekR1DistillLlama70b",
73− Provider: ProviderGROQ,
74− APIModel: "deepseek-r1-distill-llama-70b",
75− CostPer1MIn: 0.75,
76− CostPer1MInCached: 0,
77− CostPer1MOutCached: 0,
78− CostPer1MOut: 0.99,
79− ContextWindow: 128_000,
80− CanReason: true,
75+ ID: DeepseekR1DistillLlama70b,
76+ Name: "DeepseekR1DistillLlama70b",
77+ Provider: ProviderGROQ,
78+ APIModel: "deepseek-r1-distill-llama-70b",
79+ CostPer1MIn: 0.75,
80+ CostPer1MInCached: 0,
81+ CostPer1MOutCached: 0,
82+ CostPer1MOut: 0.99,
83+ ContextWindow: 128_000,
84+ CanReason: true,
85+ SupportsAttachments: false,
8186 },
8287}
internal/llm/models/models.go+12−11
@@ -8,17 +8,18 @@ type (
88)
99
1010type Model struct {
11− ID ModelID `json:"id"`
12− Name string `json:"name"`
13− Provider ModelProvider `json:"provider"`
14− APIModel string `json:"api_model"`
15− CostPer1MIn float64 `json:"cost_per_1m_in"`
16− CostPer1MOut float64 `json:"cost_per_1m_out"`
17− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19− ContextWindow int64 `json:"context_window"`
20− DefaultMaxTokens int64 `json:"default_max_tokens"`
21− CanReason bool `json:"can_reason"`
11+ ID ModelID `json:"id"`
12+ Name string `json:"name"`
13+ Provider ModelProvider `json:"provider"`
14+ APIModel string `json:"api_model"`
15+ CostPer1MIn float64 `json:"cost_per_1m_in"`
16+ CostPer1MOut float64 `json:"cost_per_1m_out"`
17+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19+ ContextWindow int64 `json:"context_window"`
20+ DefaultMaxTokens int64 `json:"default_max_tokens"`
21+ CanReason bool `json:"can_reason"`
22+ SupportsAttachments bool `json:"supports_attachments"`
2223}
2324
2425// Model IDs
internal/llm/models/openai.go+136−124
@@ -19,151 +19,163 @@ const (
1919
2020var OpenAIModels = map[ModelID]Model{
2121 GPT41: {
22− ID: GPT41,
23− Name: "GPT 4.1",
24− Provider: ProviderOpenAI,
25− APIModel: "gpt-4.1",
26− CostPer1MIn: 2.00,
27− CostPer1MInCached: 0.50,
28− CostPer1MOutCached: 0.0,
29− CostPer1MOut: 8.00,
30− ContextWindow: 1_047_576,
31− DefaultMaxTokens: 20000,
22+ ID: GPT41,
23+ Name: "GPT 4.1",
24+ Provider: ProviderOpenAI,
25+ APIModel: "gpt-4.1",
26+ CostPer1MIn: 2.00,
27+ CostPer1MInCached: 0.50,
28+ CostPer1MOutCached: 0.0,
29+ CostPer1MOut: 8.00,
30+ ContextWindow: 1_047_576,
31+ DefaultMaxTokens: 20000,
32+ SupportsAttachments: true,
3233 },
3334 GPT41Mini: {
34− ID: GPT41Mini,
35− Name: "GPT 4.1 mini",
36− Provider: ProviderOpenAI,
37− APIModel: "gpt-4.1",
38− CostPer1MIn: 0.40,
39− CostPer1MInCached: 0.10,
40− CostPer1MOutCached: 0.0,
41− CostPer1MOut: 1.60,
42− ContextWindow: 200_000,
43− DefaultMaxTokens: 20000,
35+ ID: GPT41Mini,
36+ Name: "GPT 4.1 mini",
37+ Provider: ProviderOpenAI,
38+ APIModel: "gpt-4.1",
39+ CostPer1MIn: 0.40,
40+ CostPer1MInCached: 0.10,
41+ CostPer1MOutCached: 0.0,
42+ CostPer1MOut: 1.60,
43+ ContextWindow: 200_000,
44+ DefaultMaxTokens: 20000,
45+ SupportsAttachments: true,
4446 },
4547 GPT41Nano: {
46− ID: GPT41Nano,
47− Name: "GPT 4.1 nano",
48− Provider: ProviderOpenAI,
49− APIModel: "gpt-4.1-nano",
50− CostPer1MIn: 0.10,
51− CostPer1MInCached: 0.025,
52− CostPer1MOutCached: 0.0,
53− CostPer1MOut: 0.40,
54− ContextWindow: 1_047_576,
55− DefaultMaxTokens: 20000,
48+ ID: GPT41Nano,
49+ Name: "GPT 4.1 nano",
50+ Provider: ProviderOpenAI,
51+ APIModel: "gpt-4.1-nano",
52+ CostPer1MIn: 0.10,
53+ CostPer1MInCached: 0.025,
54+ CostPer1MOutCached: 0.0,
55+ CostPer1MOut: 0.40,
56+ ContextWindow: 1_047_576,
57+ DefaultMaxTokens: 20000,
58+ SupportsAttachments: true,
5659 },
5760 GPT45Preview: {
58− ID: GPT45Preview,
59− Name: "GPT 4.5 preview",
60− Provider: ProviderOpenAI,
61− APIModel: "gpt-4.5-preview",
62− CostPer1MIn: 75.00,
63− CostPer1MInCached: 37.50,
64− CostPer1MOutCached: 0.0,
65− CostPer1MOut: 150.00,
66− ContextWindow: 128_000,
67− DefaultMaxTokens: 15000,
61+ ID: GPT45Preview,
62+ Name: "GPT 4.5 preview",
63+ Provider: ProviderOpenAI,
64+ APIModel: "gpt-4.5-preview",
65+ CostPer1MIn: 75.00,
66+ CostPer1MInCached: 37.50,
67+ CostPer1MOutCached: 0.0,
68+ CostPer1MOut: 150.00,
69+ ContextWindow: 128_000,
70+ DefaultMaxTokens: 15000,
71+ SupportsAttachments: true,
6872 },
6973 GPT4o: {
70− ID: GPT4o,
71− Name: "GPT 4o",
72− Provider: ProviderOpenAI,
73− APIModel: "gpt-4o",
74− CostPer1MIn: 2.50,
75− CostPer1MInCached: 1.25,
76− CostPer1MOutCached: 0.0,
77− CostPer1MOut: 10.00,
78− ContextWindow: 128_000,
79− DefaultMaxTokens: 4096,
74+ ID: GPT4o,
75+ Name: "GPT 4o",
76+ Provider: ProviderOpenAI,
77+ APIModel: "gpt-4o",
78+ CostPer1MIn: 2.50,
79+ CostPer1MInCached: 1.25,
80+ CostPer1MOutCached: 0.0,
81+ CostPer1MOut: 10.00,
82+ ContextWindow: 128_000,
83+ DefaultMaxTokens: 4096,
84+ SupportsAttachments: true,
8085 },
8186 GPT4oMini: {
82− ID: GPT4oMini,
83− Name: "GPT 4o mini",
84− Provider: ProviderOpenAI,
85− APIModel: "gpt-4o-mini",
86− CostPer1MIn: 0.15,
87− CostPer1MInCached: 0.075,
88− CostPer1MOutCached: 0.0,
89− CostPer1MOut: 0.60,
90− ContextWindow: 128_000,
87+ ID: GPT4oMini,
88+ Name: "GPT 4o mini",
89+ Provider: ProviderOpenAI,
90+ APIModel: "gpt-4o-mini",
91+ CostPer1MIn: 0.15,
92+ CostPer1MInCached: 0.075,
93+ CostPer1MOutCached: 0.0,
94+ CostPer1MOut: 0.60,
95+ ContextWindow: 128_000,
96+ SupportsAttachments: true,
9197 },
9298 O1: {
93− ID: O1,
94− Name: "O1",
95− Provider: ProviderOpenAI,
96− APIModel: "o1",
97− CostPer1MIn: 15.00,
98− CostPer1MInCached: 7.50,
99− CostPer1MOutCached: 0.0,
100− CostPer1MOut: 60.00,
101− ContextWindow: 200_000,
102− DefaultMaxTokens: 50000,
103− CanReason: true,
99+ ID: O1,
100+ Name: "O1",
101+ Provider: ProviderOpenAI,
102+ APIModel: "o1",
103+ CostPer1MIn: 15.00,
104+ CostPer1MInCached: 7.50,
105+ CostPer1MOutCached: 0.0,
106+ CostPer1MOut: 60.00,
107+ ContextWindow: 200_000,
108+ DefaultMaxTokens: 50000,
109+ CanReason: true,
110+ SupportsAttachments: true,
104111 },
105112 O1Pro: {
106− ID: O1Pro,
107− Name: "o1 pro",
108− Provider: ProviderOpenAI,
109− APIModel: "o1-pro",
110− CostPer1MIn: 150.00,
111− CostPer1MInCached: 0.0,
112− CostPer1MOutCached: 0.0,
113− CostPer1MOut: 600.00,
114− ContextWindow: 200_000,
115− DefaultMaxTokens: 50000,
116− CanReason: true,
113+ ID: O1Pro,
114+ Name: "o1 pro",
115+ Provider: ProviderOpenAI,
116+ APIModel: "o1-pro",
117+ CostPer1MIn: 150.00,
118+ CostPer1MInCached: 0.0,
119+ CostPer1MOutCached: 0.0,
120+ CostPer1MOut: 600.00,
121+ ContextWindow: 200_000,
122+ DefaultMaxTokens: 50000,
123+ CanReason: true,
124+ SupportsAttachments: true,
117125 },
118126 O1Mini: {
119− ID: O1Mini,
120− Name: "o1 mini",
121− Provider: ProviderOpenAI,
122− APIModel: "o1-mini",
123− CostPer1MIn: 1.10,
124− CostPer1MInCached: 0.55,
125− CostPer1MOutCached: 0.0,
126− CostPer1MOut: 4.40,
127− ContextWindow: 128_000,
128− DefaultMaxTokens: 50000,
129− CanReason: true,
127+ ID: O1Mini,
128+ Name: "o1 mini",
129+ Provider: ProviderOpenAI,
130+ APIModel: "o1-mini",
131+ CostPer1MIn: 1.10,
132+ CostPer1MInCached: 0.55,
133+ CostPer1MOutCached: 0.0,
134+ CostPer1MOut: 4.40,
135+ ContextWindow: 128_000,
136+ DefaultMaxTokens: 50000,
137+ CanReason: true,
138+ SupportsAttachments: true,
130139 },
131140 O3: {
132− ID: O3,
133− Name: "o3",
134− Provider: ProviderOpenAI,
135− APIModel: "o3",
136− CostPer1MIn: 10.00,
137− CostPer1MInCached: 2.50,
138− CostPer1MOutCached: 0.0,
139− CostPer1MOut: 40.00,
140− ContextWindow: 200_000,
141− CanReason: true,
141+ ID: O3,
142+ Name: "o3",
143+ Provider: ProviderOpenAI,
144+ APIModel: "o3",
145+ CostPer1MIn: 10.00,
146+ CostPer1MInCached: 2.50,
147+ CostPer1MOutCached: 0.0,
148+ CostPer1MOut: 40.00,
149+ ContextWindow: 200_000,
150+ CanReason: true,
151+ SupportsAttachments: true,
142152 },
143153 O3Mini: {
144− ID: O3Mini,
145− Name: "o3 mini",
146− Provider: ProviderOpenAI,
147− APIModel: "o3-mini",
148− CostPer1MIn: 1.10,
149− CostPer1MInCached: 0.55,
150− CostPer1MOutCached: 0.0,
151− CostPer1MOut: 4.40,
152− ContextWindow: 200_000,
153− DefaultMaxTokens: 50000,
154− CanReason: true,
154+ ID: O3Mini,
155+ Name: "o3 mini",
156+ Provider: ProviderOpenAI,
157+ APIModel: "o3-mini",
158+ CostPer1MIn: 1.10,
159+ CostPer1MInCached: 0.55,
160+ CostPer1MOutCached: 0.0,
161+ CostPer1MOut: 4.40,
162+ ContextWindow: 200_000,
163+ DefaultMaxTokens: 50000,
164+ CanReason: true,
165+ SupportsAttachments: false,
155166 },
156167 O4Mini: {
157− ID: O4Mini,
158− Name: "o4 mini",
159− Provider: ProviderOpenAI,
160− APIModel: "o4-mini",
161− CostPer1MIn: 1.10,
162− CostPer1MInCached: 0.275,
163− CostPer1MOutCached: 0.0,
164− CostPer1MOut: 4.40,
165− ContextWindow: 128_000,
166− DefaultMaxTokens: 50000,
167− CanReason: true,
168+ ID: O4Mini,
169+ Name: "o4 mini",
170+ Provider: ProviderOpenAI,
171+ APIModel: "o4-mini",
172+ CostPer1MIn: 1.10,
173+ CostPer1MInCached: 0.275,
174+ CostPer1MOutCached: 0.0,
175+ CostPer1MOut: 4.40,
176+ ContextWindow: 128_000,
177+ DefaultMaxTokens: 50000,
178+ CanReason: true,
179+ SupportsAttachments: true,
168180 },
169181}
internal/llm/prompt/title.go+2−1
@@ -8,5 +8,6 @@ func TitlePrompt(_ models.ModelProvider) string {
88- the title should be a summary of the user's message
99- it should be one line long
1010- 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`
1213}
internal/llm/provider/anthropic.go+13−3
@@ -13,6 +13,7 @@ import (
1313 "github.com/anthropics/anthropic-sdk-go/bedrock"
1414 "github.com/anthropics/anthropic-sdk-go/option"
1515 "github.com/opencode-ai/opencode/internal/config"
16+ "github.com/opencode-ai/opencode/internal/llm/models"
1617 "github.com/opencode-ai/opencode/internal/llm/tools"
1718 "github.com/opencode-ai/opencode/internal/logging"
1819 "github.com/opencode-ai/opencode/internal/message"
@@ -70,7 +71,14 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
7071 Type: "ephemeral",
7172 }
7273 }
73− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
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+ }
81+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
7482
7583 case message.Assistant:
7684 blocks := []anthropic.ContentBlockParamUnion{}
@@ -196,9 +204,10 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
196204 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
197205 cfg := config.Get()
198206 if cfg.Debug {
199− // jsonData, _ := json.Marshal(preparedMessages)
200− // logging.Debug("Prepared messages", "messages", string(jsonData))
207+ jsonData, _ := json.Marshal(preparedMessages)
208+ logging.Debug("Prepared messages", "messages", string(jsonData))
201209 }
210+
202211 attempts := 0
203212 for {
204213 attempts++
@@ -208,6 +217,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
208217 )
209218 // If there is an error we are going to see if we can retry the call
210219 if err != nil {
220+ logging.Error("Error in Anthropic API call", "error", err)
211221 retry, after, retryErr := a.shouldRetry(attempts, err)
212222 if retryErr != nil {
213223 return nil, retryErr
internal/llm/provider/bedrock.go+5−4
@@ -55,7 +55,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
5555 if strings.Contains(string(opts.model.APIModel), "anthropic") {
5656 // Create Anthropic client with Bedrock configuration
5757 anthropicOpts := opts
58− anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
58+ anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
5959 WithAnthropicBedrock(true),
6060 WithAnthropicDisableCache(),
6161 )
@@ -84,7 +84,7 @@ func (b *bedrockClient) send(ctx context.Context, messages []message.Message, to
8484
8585func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
8686 eventChan := make(chan ProviderEvent)
87−
87+
8888 if b.childProvider == nil {
8989 go func() {
9090 eventChan <- ProviderEvent{
@@ -95,6 +95,7 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
9595 }()
9696 return eventChan
9797 }
98−
98+
9999 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
5757 for _, msg := range messages {
5858 switch msg.Role {
5959 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+ }
6066 history = append(history, &genai.Content{
61− Parts: []genai.Part{genai.Text(msg.Content().String())},
67+ Parts: parts,
6268 Role: "user",
6369 })
64−
6570 case message.Assistant:
6671 content := &genai.Content{
6772 Role: "model",
internal/llm/provider/openai.go+12−1
@@ -12,6 +12,7 @@ import (
1212 "github.com/openai/openai-go/option"
1313 "github.com/openai/openai-go/shared"
1414 "github.com/opencode-ai/opencode/internal/config"
15+ "github.com/opencode-ai/opencode/internal/llm/models"
1516 "github.com/opencode-ai/opencode/internal/llm/tools"
1617 "github.com/opencode-ai/opencode/internal/logging"
1718 "github.com/opencode-ai/opencode/internal/message"
@@ -71,7 +72,17 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7172 for _, msg := range messages {
7273 switch msg.Role {
7374 case message.User:
74− openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
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+
85+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
7586
7687 case message.Assistant:
7788 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 {
6666func (ImageURLContent) isPart() {}
6767
6868type BinaryContent struct {
69+ Path string
6970 MIMEType string
7071 Data []byte
7172}
7273
73−func (bc BinaryContent) String() string {
74+func (bc BinaryContent) String(provider models.ModelProvider) string {
7475 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
75− return "data:" + bc.MIMEType + ";base64," + base64Encoded
76+ if provider == models.ProviderOpenAI {
77+ return "data:" + bc.MIMEType + ";base64," + base64Encoded
78+ }
79+ return base64Encoded
7680}
7781
7882func (BinaryContent) isPart() {}
@@ -110,7 +114,6 @@ type Message struct {
110114 SessionID string
111115 Parts []ContentPart
112116 Model models.ModelID
113−
114117 CreatedAt int64
115118 UpdatedAt int64
116119}
internal/message/message.go−1
@@ -64,7 +64,6 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
6464 if err != nil {
6565 return Message{}, err
6666 }
67−
6867 dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
6968 ID: uuid.New().String(),
7069 SessionID: sessionID,
internal/tui/components/chat/chat.go+3−1
@@ -7,6 +7,7 @@ import (
77 "github.com/charmbracelet/lipgloss"
88 "github.com/charmbracelet/x/ansi"
99 "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/opencode-ai/opencode/internal/message"
1011 "github.com/opencode-ai/opencode/internal/session"
1112 "github.com/opencode-ai/opencode/internal/tui/styles"
1213 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -14,7 +15,8 @@ import (
1415)
1516
1617type SendMsg struct {
17− Text string
18+ Text string
19+ Attachments []message.Attachment
1820}
1921
2022type SessionSelectedMsg = session.Session
internal/tui/components/chat/editor.go+115−10
@@ -1,14 +1,19 @@
11package chat
22
33import (
4+ "fmt"
45 "os"
56 "os/exec"
7+ "slices"
8+ "unicode"
69
710 "github.com/charmbracelet/bubbles/key"
811 "github.com/charmbracelet/bubbles/textarea"
912 tea "github.com/charmbracelet/bubbletea"
1013 "github.com/charmbracelet/lipgloss"
1114 "github.com/opencode-ai/opencode/internal/app"
15+ "github.com/opencode-ai/opencode/internal/logging"
16+ "github.com/opencode-ai/opencode/internal/message"
1217 "github.com/opencode-ai/opencode/internal/session"
1318 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
1419 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -18,9 +23,13 @@ import (
1823)
1924
2025type editorCmp struct {
21− app *app.App
22− session session.Session
23− textarea textarea.Model
26+ width int
27+ height int
28+ app *app.App
29+ session session.Session
30+ textarea textarea.Model
31+ attachments []message.Attachment
32+ deleteMode bool
2433}
2534
2635type EditorKeyMaps struct {
@@ -33,6 +42,11 @@ type bluredEditorKeyMaps struct {
3342 Focus key.Binding
3443 OpenEditor key.Binding
3544}
45+type DeleteAttachmentKeyMaps struct {
46+ AttachmentDeleteMode key.Binding
47+ Escape key.Binding
48+ DeleteAllAttachments key.Binding
49+}
3650
3751var editorMaps = EditorKeyMaps{
3852 Send: key.NewBinding(
@@ -45,7 +59,26 @@ var editorMaps = EditorKeyMaps{
4559 ),
4660}
4761
48−func openEditor() tea.Cmd {
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+
81+func (m *editorCmp) openEditor() tea.Cmd {
4982 editor := os.Getenv("EDITOR")
5083 if editor == "" {
5184 editor = "nvim"
@@ -72,8 +105,11 @@ func openEditor() tea.Cmd {
72105 return util.ReportWarn("Message is empty")
73106 }
74107 os.Remove(tmpfile.Name())
108+ attachments := m.attachments
109+ m.attachments = nil
75110 return SendMsg{
76− Text: string(content),
111+ Text: string(content),
112+ Attachments: attachments,
77113 }
78114 })
79115}
@@ -89,12 +125,16 @@ func (m *editorCmp) send() tea.Cmd {
89125
90126 value := m.textarea.Value()
91127 m.textarea.Reset()
128+ attachments := m.attachments
129+
130+ m.attachments = nil
92131 if value == "" {
93132 return nil
94133 }
95134 return tea.Batch(
96135 util.CmdHandler(SendMsg{
97− Text: value,
136+ Text: value,
137+ Attachments: attachments,
98138 }),
99139 )
100140}
@@ -110,7 +150,34 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110150 m.session = msg
111151 }
112152 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)
113159 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+ }
114181 if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
115182 key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
116183 return m, nil
@@ -119,7 +186,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119186 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
120187 return m, util.ReportWarn("Agent is working, please wait...")
121188 }
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
123194 }
124195 // Handle Enter key
125196 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
@@ -133,6 +204,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
133204 return m, m.send()
134205 }
135206 }
207+
136208 }
137209 m.textarea, cmd = m.textarea.Update(msg)
138210 return m, cmd
@@ -147,12 +219,23 @@ func (m *editorCmp) View() string {
147219 Bold(true).
148220 Foreground(t.Primary())
149221
150− return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
222+ if len(m.attachments) == 0 {
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+ )
151231}
152232
153233func (m *editorCmp) SetSize(width, height int) tea.Cmd {
234+ m.width = width
235+ m.height = height
154236 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
155237 m.textarea.SetHeight(height)
238+ m.textarea.SetWidth(width)
156239 return nil
157240}
158241
@@ -160,9 +243,33 @@ func (m *editorCmp) GetSize() (int, int) {
160243 return m.textarea.Width(), m.textarea.Height()
161244}
162245
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+
163269func (m *editorCmp) BindingKeys() []key.Binding {
164270 bindings := []key.Binding{}
165271 bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
272+ bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
166273 return bindings
167274}
168275
@@ -198,10 +305,8 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
198305
199306func NewEditorCmp(app *app.App) tea.Model {
200307 ta := CreateTextArea(nil)
201−
202308 return &editorCmp{
203309 app: app,
204310 textarea: ta,
205311 }
206312}
207−
internal/tui/components/chat/list.go+13−3
@@ -35,6 +35,7 @@ type messagesCmp struct {
3535 cachedContent map[string]cacheItem
3636 spinner spinner.Model
3737 rendering bool
38+ attachments viewport.Model
3839}
3940type renderFinishedMsg struct{}
4041
@@ -229,12 +230,15 @@ func (m *messagesCmp) renderView() {
229230
230231 messages := make([]string, 0)
231232 for _, v := range m.uiMessages {
232− messages = append(messages, v.content,
233+ messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
233234 baseStyle.
234235 Width(m.width).
235− Render(""),
236+ Render(
237+ "",
238+ ),
236239 )
237240 }
241+
238242 m.viewport.SetContent(
239243 baseStyle.
240244 Width(m.width).
@@ -413,6 +417,8 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
413417 m.height = height
414418 m.viewport.Width = width
415419 m.viewport.Height = height - 2
420+ m.attachments.Width = width + 40
421+ m.attachments.Height = 3
416422 m.rerender()
417423 return nil
418424}
@@ -431,7 +437,9 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
431437 return util.ReportError(err)
432438 }
433439 m.messages = messages
434− m.currentMsgID = m.messages[len(m.messages)-1].ID
440+ if len(m.messages) > 0 {
441+ m.currentMsgID = m.messages[len(m.messages)-1].ID
442+ }
435443 delete(m.cachedContent, m.currentMsgID)
436444 m.rendering = true
437445 return func() tea.Msg {
@@ -453,6 +461,7 @@ func NewMessagesCmp(app *app.App) tea.Model {
453461 s := spinner.New()
454462 s.Spinner = spinner.Pulse
455463 vp := viewport.New(0, 0)
464+ attachmets := viewport.New(0, 0)
456465 vp.KeyMap.PageUp = messageKeys.PageUp
457466 vp.KeyMap.PageDown = messageKeys.PageDown
458467 vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
@@ -462,5 +471,6 @@ func NewMessagesCmp(app *app.App) tea.Model {
462471 cachedContent: make(map[string]cacheItem),
463472 viewport: vp,
464473 spinner: s,
474+ attachments: attachmets,
465475 }
466476}
internal/tui/components/chat/message.go+23−1
@@ -80,7 +80,29 @@ func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...s
8080}
8181
8282func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
83− content := renderMessage(msg.Content().String(), true, isFocused, width)
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 {
104+ content = renderMessage(msg.Content().String(), true, isFocused, width)
105+ }
84106 userMsg := uiMessage{
85107 ID: msg.ID,
86108 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 {
7474 var (
7575 pairs []string
7676 width int
77− rows = 10 - 2
77+ rows = 12 - 2
7878 )
7979
8080 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 {
270270
271271func (m *modelDialogCmp) setupModels() {
272272 cfg := config.Get()
273−
273+ modelInfo := GetSelectedModel(cfg)
274274 m.availableProviders = getEnabledProviders(cfg)
275275 m.hScrollPossible = len(m.availableProviders) > 1
276276
277− agentCfg := cfg.Agents[config.AgentCoder]
278− selectedModelId := agentCfg.Model
279− modelInfo := models.SupportedModels[selectedModelId]
280−
281277 m.provider = modelInfo.Provider
282278 m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
283279
284280 m.setupModelsForProvider(m.provider)
285281}
286282
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+
287290func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
288291 var providers []models.ModelProvider
289292 for providerId, provider := range cfg.Providers {
internal/tui/components/dialog/permission.go+1−2
@@ -2,8 +2,6 @@ package dialog
22
33import (
44 "fmt"
5− "strings"
6−
75 "github.com/charmbracelet/bubbles/key"
86 "github.com/charmbracelet/bubbles/viewport"
97 tea "github.com/charmbracelet/bubbletea"
@@ -15,6 +13,7 @@ import (
1513 "github.com/opencode-ai/opencode/internal/tui/styles"
1614 "github.com/opencode-ai/opencode/internal/tui/theme"
1715 "github.com/opencode-ai/opencode/internal/tui/util"
16+ "strings"
1817)
1918
2019type 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 (
66 "github.com/charmbracelet/bubbles/key"
77 tea "github.com/charmbracelet/bubbletea"
88 "github.com/opencode-ai/opencode/internal/app"
9+ "github.com/opencode-ai/opencode/internal/message"
910 "github.com/opencode-ai/opencode/internal/session"
1011 "github.com/opencode-ai/opencode/internal/tui/components/chat"
1112 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -52,7 +53,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5253 cmd := p.layout.SetSize(msg.Width, msg.Height)
5354 cmds = append(cmds, cmd)
5455 case chat.SendMsg:
55− cmd := p.sendMessage(msg.Text)
56+ cmd := p.sendMessage(msg.Text, msg.Attachments)
5657 if cmd != nil {
5758 return p, cmd
5859 }
@@ -99,7 +100,7 @@ func (p *chatPage) clearSidebar() tea.Cmd {
99100 return p.layout.ClearRightPanel()
100101}
101102
102−func (p *chatPage) sendMessage(text string) tea.Cmd {
103+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
103104 var cmds []tea.Cmd
104105 if p.session.ID == "" {
105106 session, err := p.app.Sessions.Create(context.Background(), "New Session")
@@ -115,7 +116,10 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
115116 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
116117 }
117118
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+ }
119123 return tea.Batch(cmds...)
120124}
121125
@@ -134,6 +138,7 @@ func (p *chatPage) View() string {
134138func (p *chatPage) BindingKeys() []key.Binding {
135139 bindings := layout.KeyMapToSlice(keyMap)
136140 bindings = append(bindings, p.messages.BindingKeys()...)
141+ bindings = append(bindings, p.editor.BindingKeys()...)
137142 return bindings
138143}
139144
internal/tui/styles/icons.go+9−8
@@ -3,11 +3,12 @@ package styles
33const (
44 OpenCodeIcon string = "⌬"
55
6− CheckIcon string = "✓"
7− ErrorIcon string = "✖"
8− WarningIcon string = "⚠"
9− InfoIcon string = ""
10− HintIcon string = "i"
11− SpinnerIcon string = "..."
12− LoadingIcon string = "⟳"
13−)
6+ CheckIcon string = "✓"
7+ ErrorIcon string = "✖"
8+ WarningIcon string = "⚠"
9+ InfoIcon string = ""
10+ HintIcon string = "i"
11+ SpinnerIcon string = "..."
12+ LoadingIcon string = "⟳"
13+ DocumentIcon string = "🖼"
14+)
internal/tui/styles/styles.go+4−1
@@ -5,6 +5,10 @@ import (
55 "github.com/opencode-ai/opencode/internal/tui/theme"
66)
77
8+var (
9+ ImageBakcground = "#212121"
10+)
11+
812// Style generation functions that use the current theme
913
1014// BaseStyle returns the base style with background and foreground colors
@@ -149,4 +153,3 @@ func BorderFocusedColor() lipgloss.AdaptiveColor {
149153func BorderDimColor() lipgloss.AdaptiveColor {
150154 return theme.CurrentTheme().BorderDim()
151155}
152−
internal/tui/tui.go+89−22
@@ -26,10 +26,15 @@ type keyMap struct {
2626 Help key.Binding
2727 SwitchSession key.Binding
2828 Commands key.Binding
29+ Filepicker key.Binding
2930 Models key.Binding
3031 SwitchTheme key.Binding
3132}
3233
34+const (
35+ quitKey = "q"
36+)
37+
3338var keys = keyMap{
3439 Logs: key.NewBinding(
3540 key.WithKeys("ctrl+l"),
@@ -54,7 +59,10 @@ var keys = keyMap{
5459 key.WithKeys("ctrl+k"),
5560 key.WithHelp("ctrl+k", "commands"),
5661 ),
57−
62+ Filepicker: key.NewBinding(
63+ key.WithKeys("ctrl+f"),
64+ key.WithHelp("ctrl+f", "select files to upload"),
65+ ),
5866 Models: key.NewBinding(
5967 key.WithKeys("ctrl+o"),
6068 key.WithHelp("ctrl+o", "model selection"),
@@ -77,7 +85,7 @@ var returnKey = key.NewBinding(
7785)
7886
7987var logsKeyReturnKey = key.NewBinding(
80− key.WithKeys("esc", "backspace", "q"),
88+ key.WithKeys("esc", "backspace", quitKey),
8189 key.WithHelp("esc/q", "go back"),
8290)
8391
@@ -112,6 +120,9 @@ type appModel struct {
112120 showInitDialog bool
113121 initDialog dialog.InitDialogCmp
114122
123+ showFilepicker bool
124+ filepicker dialog.FilepickerCmp
125+
115126 showThemeDialog bool
116127 themeDialog dialog.ThemeDialog
117128}
@@ -135,6 +146,7 @@ func (a appModel) Init() tea.Cmd {
135146 cmds = append(cmds, cmd)
136147 cmd = a.initDialog.Init()
137148 cmds = append(cmds, cmd)
149+ cmd = a.filepicker.Init()
138150 cmd = a.themeDialog.Init()
139151 cmds = append(cmds, cmd)
140152
@@ -182,6 +194,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182194 a.commandDialog = command.(dialog.CommandDialog)
183195 cmds = append(cmds, commandCmd)
184196
197+ filepicker, filepickerCmd := a.filepicker.Update(msg)
198+ a.filepicker = filepicker.(dialog.FilepickerCmp)
199+ cmds = append(cmds, filepickerCmd)
200+
185201 a.initDialog.SetSize(msg.Width, msg.Height)
186202
187203 return a, tea.Batch(cmds...)
@@ -333,6 +349,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333349
334350 case tea.KeyMsg:
335351 switch {
352+
336353 case key.Matches(msg, keys.Quit):
337354 a.showQuit = !a.showQuit
338355 if a.showHelp {
@@ -344,6 +361,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
344361 if a.showCommandDialog {
345362 a.showCommandDialog = false
346363 }
364+ if a.showFilepicker {
365+ a.showFilepicker = false
366+ a.filepicker.ToggleFilepicker(a.showFilepicker)
367+ }
347368 if a.showModelDialog {
348369 a.showModelDialog = false
349370 }
@@ -364,7 +385,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
364385 }
365386 return a, nil
366387 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 {
368389 // Show commands dialog
369390 if len(a.commands) == 0 {
370391 return a, util.ReportWarn("No commands available")
@@ -392,26 +413,36 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
392413 return a, a.themeDialog.Init()
393414 }
394415 return a, nil
395− case key.Matches(msg, logsKeyReturnKey):
396− if a.currentPage == page.LogsPage {
397− return a, a.moveToPage(page.ChatPage)
398− }
399− case key.Matches(msg, returnKey):
400− if a.showQuit {
401− a.showQuit = !a.showQuit
402− return a, nil
403− }
404− if a.showHelp {
405− a.showHelp = !a.showHelp
406− return a, nil
407− }
408− if a.showInitDialog {
409− a.showInitDialog = false
410− // Mark the project as initialized without running the command
411− if err := config.MarkProjectInitialized(); err != nil {
412− return a, util.ReportError(err)
416+ case key.Matches(msg, returnKey) || key.Matches(msg):
417+ if msg.String() == quitKey {
418+ if a.currentPage == page.LogsPage {
419+ return a, a.moveToPage(page.ChatPage)
420+ }
421+ } else if !a.filepicker.IsCWDFocused() {
422+ if a.showQuit {
423+ a.showQuit = !a.showQuit
424+ return a, nil
425+ }
426+ if a.showHelp {
427+ a.showHelp = !a.showHelp
428+ return a, nil
429+ }
430+ if a.showInitDialog {
431+ a.showInitDialog = false
432+ // Mark the project as initialized without running the command
433+ if err := config.MarkProjectInitialized(); err != nil {
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)
413445 }
414− return a, nil
415446 }
416447 case key.Matches(msg, keys.Logs):
417448 return a, a.moveToPage(page.LogsPage)
@@ -429,10 +460,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
429460 a.showHelp = !a.showHelp
430461 return a, nil
431462 }
463+ case key.Matches(msg, keys.Filepicker):
464+ a.showFilepicker = !a.showFilepicker
465+ a.filepicker.ToggleFilepicker(a.showFilepicker)
466+ return a, nil
432467 }
468+ default:
469+ f, filepickerCmd := a.filepicker.Update(msg)
470+ a.filepicker = f.(dialog.FilepickerCmp)
471+ cmds = append(cmds, filepickerCmd)
433472
434473 }
435474
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+
436485 if a.showQuit {
437486 q, quitCmd := a.quit.Update(msg)
438487 a.quit = q.(dialog.QuitDialog)
@@ -519,6 +568,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
519568 // For now we don't move to any page if the agent is busy
520569 return util.ReportWarn("Agent is busy, please wait...")
521570 }
571+
522572 var cmds []tea.Cmd
523573 if _, ok := a.loadedPages[pageID]; !ok {
524574 cmd := a.pages[pageID].Init()
@@ -559,6 +609,22 @@ func (a appModel) View() string {
559609 )
560610 }
561611
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+
562628 if !a.app.CoderAgent.IsBusy() {
563629 a.status.SetHelpWidgetMsg("ctrl+? help")
564630 } else {
@@ -704,6 +770,7 @@ func New(app *app.App) tea.Model {
704770 page.ChatPage: page.NewChatPage(app),
705771 page.LogsPage: page.NewLogsPage(),
706772 },
773+ filepicker: dialog.NewFilepickerCmp(app),
707774 }
708775
709776 model.RegisterCommand(dialog.Command{