diff options
| author | phantomreactor <[email protected]> | 2025-05-03 01:53:58 +0530 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-05-02 15:29:46 -0500 |
| commit | ff0ef3bb432f1cedb6e5b8a0168bfa7c9e9e15f0 (patch) | |
| tree | e027f8eee09fafe33b98c6316d84b0f5e6a8edc0 /internal/tui/components/chat | |
| parent | 0095832be3b6c9ae9c45dfed70ecd22302e08dc9 (diff) | |
| download | opencode-ff0ef3bb432f1cedb6e5b8a0168bfa7c9e9e15f0.tar.gz opencode-ff0ef3bb432f1cedb6e5b8a0168bfa7c9e9e15f0.zip | |
feat: add support for images
Diffstat (limited to 'internal/tui/components/chat')
| -rw-r--r-- | internal/tui/components/chat/chat.go | 4 | ||||
| -rw-r--r-- | internal/tui/components/chat/editor.go | 124 | ||||
| -rw-r--r-- | internal/tui/components/chat/list.go | 16 | ||||
| -rw-r--r-- | internal/tui/components/chat/message.go | 24 |
4 files changed, 154 insertions, 14 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 52c9a4f71..59a92ca3c 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" @@ -14,7 +15,8 @@ import ( ) type SendMsg struct { - Text string + Text string + Attachments []message.Attachment } type SessionSelectedMsg = session.Session diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index fea5d108b..982415182 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -1,14 +1,19 @@ package chat import ( + "fmt" "os" "os/exec" + "slices" + "unicode" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/opencode-ai/opencode/internal/app" + "github.com/opencode-ai/opencode/internal/logging" + "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/dialog" "github.com/opencode-ai/opencode/internal/tui/layout" @@ -18,9 +23,13 @@ import ( ) type editorCmp struct { - app *app.App - session session.Session - textarea textarea.Model + width int + height int + app *app.App + session session.Session + textarea textarea.Model + attachments []message.Attachment + deleteMode bool } type EditorKeyMaps struct { @@ -33,6 +42,11 @@ type bluredEditorKeyMaps struct { Focus key.Binding OpenEditor key.Binding } +type DeleteAttachmentKeyMaps struct { + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding +} var editorMaps = EditorKeyMaps{ Send: key.NewBinding( @@ -45,7 +59,26 @@ var editorMaps = EditorKeyMaps{ ), } -func openEditor(value string) tea.Cmd { +var DeleteKeyMaps = DeleteAttachmentKeyMaps{ + AttachmentDeleteMode: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel delete mode"), + ), + DeleteAllAttachments: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attchments"), + ), +} + +const ( + maxAttachments = 5 +) + +func (m *editorCmp) openEditor(value string) tea.Cmd { editor := os.Getenv("EDITOR") if editor == "" { editor = "nvim" @@ -73,8 +106,11 @@ func openEditor(value string) tea.Cmd { return util.ReportWarn("Message is empty") } os.Remove(tmpfile.Name()) + attachments := m.attachments + m.attachments = nil return SendMsg{ - Text: string(content), + Text: string(content), + Attachments: attachments, } }) } @@ -90,12 +126,16 @@ func (m *editorCmp) send() tea.Cmd { value := m.textarea.Value() m.textarea.Reset() + attachments := m.attachments + + m.attachments = nil if value == "" { return nil } return tea.Batch( util.CmdHandler(SendMsg{ - Text: value, + Text: value, + Attachments: attachments, }), ) } @@ -111,7 +151,34 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.session = msg } return m, nil + case dialog.AttachmentAddedMsg: + if len(m.attachments) >= maxAttachments { + logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments)) + return m, cmd + } + m.attachments = append(m.attachments, msg.Attachment) case tea.KeyMsg: + if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { + m.deleteMode = true + return m, nil + } + if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode { + m.deleteMode = false + m.attachments = nil + return m, nil + } + if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) { + num := int(msg.Runes[0] - '0') + m.deleteMode = false + if num < 10 && len(m.attachments) > num { + if num == 0 { + m.attachments = m.attachments[num+1:] + } else { + m.attachments = slices.Delete(m.attachments, num, num+1) + } + return m, nil + } + } if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) || key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) { return m, nil @@ -122,7 +189,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } value := m.textarea.Value() m.textarea.Reset() - return m, openEditor(value) + return m, m.openEditor(value) + } + if key.Matches(msg, DeleteKeyMaps.Escape) { + m.deleteMode = false + return m, nil } // Handle Enter key if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) { @@ -136,6 +207,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.send() } } + } m.textarea, cmd = m.textarea.Update(msg) return m, cmd @@ -150,12 +222,23 @@ func (m *editorCmp) View() string { Bold(true). Foreground(t.Primary()) - return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) + if len(m.attachments) == 0 { + return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) + } + m.textarea.SetHeight(m.height - 1) + return lipgloss.JoinVertical(lipgloss.Top, + m.attachmentsContent(), + lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), + m.textarea.View()), + ) } func (m *editorCmp) SetSize(width, height int) tea.Cmd { + m.width = width + m.height = height m.textarea.SetWidth(width - 3) // account for the prompt and padding right m.textarea.SetHeight(height) + m.textarea.SetWidth(width) return nil } @@ -163,9 +246,33 @@ func (m *editorCmp) GetSize() (int, int) { return m.textarea.Width(), m.textarea.Height() } +func (m *editorCmp) attachmentsContent() string { + var styledAttachments []string + t := theme.CurrentTheme() + attachmentStyles := styles.BaseStyle(). + MarginLeft(1). + Background(t.TextMuted()). + Foreground(t.Text()) + for i, attachment := range m.attachments { + var filename string + if len(attachment.FileName) > 10 { + filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7]) + } else { + filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName) + } + if m.deleteMode { + filename = fmt.Sprintf("%d%s", i, filename) + } + styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) + } + content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) + return content +} + func (m *editorCmp) BindingKeys() []key.Binding { bindings := []key.Binding{} bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...) + bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...) return bindings } @@ -201,7 +308,6 @@ func CreateTextArea(existing *textarea.Model) textarea.Model { func NewEditorCmp(app *app.App) tea.Model { ta := CreateTextArea(nil) - return &editorCmp{ app: app, textarea: ta, diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 2bddb19da..e08a103ea 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -36,6 +36,7 @@ type messagesCmp struct { cachedContent map[string]cacheItem spinner spinner.Model rendering bool + attachments viewport.Model } type renderFinishedMsg struct{} @@ -230,12 +231,15 @@ func (m *messagesCmp) renderView() { messages := make([]string, 0) for _, v := range m.uiMessages { - messages = append(messages, v.content, + messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content), baseStyle. Width(m.width). - Render(""), + Render( + "", + ), ) } + m.viewport.SetContent( baseStyle. Width(m.width). @@ -414,6 +418,8 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd { m.height = height m.viewport.Width = width m.viewport.Height = height - 2 + m.attachments.Width = width + 40 + m.attachments.Height = 3 m.rerender() return nil } @@ -432,7 +438,9 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { return util.ReportError(err) } m.messages = messages - m.currentMsgID = m.messages[len(m.messages)-1].ID + if len(m.messages) > 0 { + m.currentMsgID = m.messages[len(m.messages)-1].ID + } delete(m.cachedContent, m.currentMsgID) m.rendering = true return func() tea.Msg { @@ -457,6 +465,7 @@ func NewMessagesCmp(app *app.App) tea.Model { } s := spinner.New(spinner.WithSpinner(customSpinner)) vp := viewport.New(0, 0) + attachmets := viewport.New(0, 0) vp.KeyMap.PageUp = messageKeys.PageUp vp.KeyMap.PageDown = messageKeys.PageDown vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp @@ -466,5 +475,6 @@ func NewMessagesCmp(app *app.App) tea.Model { cachedContent: make(map[string]cacheItem), viewport: vp, spinner: s, + attachments: attachmets, } } diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go index ed9906b6f..6711ab525 100644 --- a/internal/tui/components/chat/message.go +++ b/internal/tui/components/chat/message.go @@ -80,7 +80,29 @@ func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...s } func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage { - content := renderMessage(msg.Content().String(), true, isFocused, width) + var styledAttachments []string + t := theme.CurrentTheme() + attachmentStyles := styles.BaseStyle(). + MarginLeft(1). + Background(t.TextMuted()). + Foreground(t.Text()) + for _, attachment := range msg.BinaryContent() { + file := filepath.Base(attachment.Path) + var filename string + if len(file) > 10 { + filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7]) + } else { + filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file) + } + styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) + } + content := "" + if len(styledAttachments) > 0 { + attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)) + content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent) + } else { + content = renderMessage(msg.Content().String(), true, isFocused, width) + } userMsg := uiMessage{ ID: msg.ID, messageType: userMessageType, |
