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 | |
| parent | 0095832be3b6c9ae9c45dfed70ecd22302e08dc9 (diff) | |
| download | opencode-ff0ef3bb432f1cedb6e5b8a0168bfa7c9e9e15f0.tar.gz opencode-ff0ef3bb432f1cedb6e5b8a0168bfa7c9e9e15f0.zip | |
feat: add support for images
Diffstat (limited to 'internal/tui')
| -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 | ||||
| -rw-r--r-- | internal/tui/components/dialog/filepicker.go | 477 | ||||
| -rw-r--r-- | internal/tui/components/dialog/help.go | 2 | ||||
| -rw-r--r-- | internal/tui/components/dialog/models.go | 13 | ||||
| -rw-r--r-- | internal/tui/components/dialog/permission.go | 3 | ||||
| -rw-r--r-- | internal/tui/image/images.go | 72 | ||||
| -rw-r--r-- | internal/tui/page/chat.go | 11 | ||||
| -rw-r--r-- | internal/tui/styles/icons.go | 11 | ||||
| -rw-r--r-- | internal/tui/styles/styles.go | 4 | ||||
| -rw-r--r-- | internal/tui/tui.go | 111 |
13 files changed, 820 insertions, 52 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, diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go new file mode 100644 index 000000000..a61c8ef54 --- /dev/null +++ b/internal/tui/components/dialog/filepicker.go @@ -0,0 +1,477 @@ +package dialog + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opencode-ai/opencode/internal/app" + "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/logging" + "github.com/opencode-ai/opencode/internal/message" + "github.com/opencode-ai/opencode/internal/tui/image" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +const ( + maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB + downArrow = "down" + upArrow = "up" +) + +type FilePrickerKeyMap struct { + Enter key.Binding + Down key.Binding + Up key.Binding + Forward key.Binding + Backward key.Binding + OpenFilePicker key.Binding + Esc key.Binding + InsertCWD key.Binding +} + +var filePickerKeyMap = FilePrickerKeyMap{ + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select file/enter directory"), + ), + Down: key.NewBinding( + key.WithKeys("j", downArrow), + key.WithHelp("↓/j", "down"), + ), + Up: key.NewBinding( + key.WithKeys("k", upArrow), + key.WithHelp("↑/k", "up"), + ), + Forward: key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "enter directory"), + ), + Backward: key.NewBinding( + key.WithKeys("h", "backspace"), + key.WithHelp("h/backspace", "go back"), + ), + OpenFilePicker: key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "open file picker"), + ), + Esc: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close/exit"), + ), + InsertCWD: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "manual path input"), + ), +} + +type filepickerCmp struct { + basePath string + width int + height int + cursor int + err error + cursorChain stack + viewport viewport.Model + dirs []os.DirEntry + cwdDetails *DirNode + selectedFile string + cwd textinput.Model + ShowFilePicker bool + app *app.App +} + +type DirNode struct { + parent *DirNode + child *DirNode + directory string +} +type stack []int + +func (s stack) Push(v int) stack { + return append(s, v) +} + +func (s stack) Pop() (stack, int) { + l := len(s) + return s[:l-1], s[l-1] +} + +type AttachmentAddedMsg struct { + Attachment message.Attachment +} + +func (f *filepickerCmp) Init() tea.Cmd { + return nil +} + +func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.width = 60 + f.height = 20 + f.viewport.Width = 80 + f.viewport.Height = 22 + f.cursor = 0 + f.getCurrentFileBelowCursor() + case tea.KeyMsg: + switch { + case key.Matches(msg, filePickerKeyMap.InsertCWD): + f.cwd.Focus() + return f, cmd + case key.Matches(msg, filePickerKeyMap.Esc): + if f.cwd.Focused() { + f.cwd.Blur() + } + case key.Matches(msg, filePickerKeyMap.Down): + if !f.cwd.Focused() || msg.String() == downArrow { + if f.cursor < len(f.dirs)-1 { + f.cursor++ + f.getCurrentFileBelowCursor() + } + } + case key.Matches(msg, filePickerKeyMap.Up): + if !f.cwd.Focused() || msg.String() == upArrow { + if f.cursor > 0 { + f.cursor-- + f.getCurrentFileBelowCursor() + } + } + case key.Matches(msg, filePickerKeyMap.Enter): + var path string + var isPathDir bool + if f.cwd.Focused() { + path = f.cwd.Value() + fileInfo, err := os.Stat(path) + if err != nil { + logging.ErrorPersist("Invalid path") + return f, cmd + } + isPathDir = fileInfo.IsDir() + } else { + path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) + isPathDir = f.dirs[f.cursor].IsDir() + } + if isPathDir { + path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) + newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} + f.cwdDetails.child = &newWorkingDir + f.cwdDetails = f.cwdDetails.child + f.cursorChain = f.cursorChain.Push(f.cursor) + f.dirs = readDir(f.cwdDetails.directory, false) + f.cursor = 0 + f.cwd.SetValue(f.cwdDetails.directory) + f.getCurrentFileBelowCursor() + } else { + f.selectedFile = path + return f.addAttachmentToMessage() + } + case key.Matches(msg, filePickerKeyMap.Esc): + if !f.cwd.Focused() { + f.cursorChain = make(stack, 0) + f.cursor = 0 + } else { + f.cwd.Blur() + } + case key.Matches(msg, filePickerKeyMap.Forward): + if !f.cwd.Focused() { + if f.dirs[f.cursor].IsDir() { + path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) + newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} + f.cwdDetails.child = &newWorkingDir + f.cwdDetails = f.cwdDetails.child + f.cursorChain = f.cursorChain.Push(f.cursor) + f.dirs = readDir(f.cwdDetails.directory, false) + f.cursor = 0 + f.cwd.SetValue(f.cwdDetails.directory) + f.getCurrentFileBelowCursor() + } + } + case key.Matches(msg, filePickerKeyMap.Backward): + if !f.cwd.Focused() { + if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil { + f.cursorChain, f.cursor = f.cursorChain.Pop() + f.cwdDetails = f.cwdDetails.parent + f.cwdDetails.child = nil + f.dirs = readDir(f.cwdDetails.directory, false) + f.cwd.SetValue(f.cwdDetails.directory) + f.getCurrentFileBelowCursor() + } + } + case key.Matches(msg, filePickerKeyMap.OpenFilePicker): + f.dirs = readDir(f.cwdDetails.directory, false) + f.cursor = 0 + f.getCurrentFileBelowCursor() + } + } + if f.cwd.Focused() { + f.cwd, cmd = f.cwd.Update(msg) + } + return f, cmd +} + +func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) { + modeInfo := GetSelectedModel(config.Get()) + if !modeInfo.SupportsAttachments { + logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name)) + return f, nil + } + if isExtSupported(f.dirs[f.cursor].Name()) { + f.selectedFile = f.dirs[f.cursor].Name() + selectedFilePath := filepath.Join(f.cwdDetails.directory, "/", f.selectedFile) + isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize) + if err != nil { + logging.ErrorPersist("unable to read the image") + return f, nil + } + if isFileLarge { + logging.ErrorPersist("file too large, max 5MB") + return f, nil + } + + content, err := os.ReadFile(selectedFilePath) + if err != nil { + logging.ErrorPersist("Unable read selected file") + return f, nil + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := f.selectedFile + attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content} + f.selectedFile = "" + return f, util.CmdHandler(AttachmentAddedMsg{attachment}) + } + if !isExtSupported(f.selectedFile) { + logging.ErrorPersist("Unsupported file") + return f, nil + } + return f, nil +} + +func (f *filepickerCmp) View() string { + t := theme.CurrentTheme() + const maxVisibleDirs = 20 + const maxWidth = 80 + + adjustedWidth := maxWidth + for _, file := range f.dirs { + if len(file.Name()) > adjustedWidth-4 { // Account for padding + adjustedWidth = len(file.Name()) + 4 + } + } + adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1 + + files := make([]string, 0, maxVisibleDirs) + startIdx := 0 + + if len(f.dirs) > maxVisibleDirs { + halfVisible := maxVisibleDirs / 2 + if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible { + startIdx = f.cursor - halfVisible + } else if f.cursor >= len(f.dirs)-halfVisible { + startIdx = len(f.dirs) - maxVisibleDirs + } + } + + endIdx := min(startIdx+maxVisibleDirs, len(f.dirs)) + + for i := startIdx; i < endIdx; i++ { + file := f.dirs[i] + itemStyle := styles.BaseStyle().Width(adjustedWidth) + + if i == f.cursor { + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.Background()). + Bold(true) + } + filename := file.Name() + + if len(filename) > adjustedWidth-4 { + filename = filename[:adjustedWidth-7] + "..." + } + if file.IsDir() { + filename = filename + "/" + } else if isExtSupported(file.Name()) { + filename = filename + } else { + filename = filename + } + + files = append(files, itemStyle.Padding(0, 1).Render(filename)) + } + + // Pad to always show exactly 21 lines + for len(files) < maxVisibleDirs { + files = append(files, styles.BaseStyle().Width(adjustedWidth).Render("")) + } + + currentPath := styles.BaseStyle(). + Height(1). + Width(adjustedWidth). + Render(f.cwd.View()) + + viewportstyle := lipgloss.NewStyle(). + Width(f.viewport.Width). + Background(t.Background()). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.TextMuted()). + BorderBackground(t.Background()). + Padding(2). + Render(f.viewport.View()) + var insertExitText string + if f.IsCWDFocused() { + insertExitText = "Press esc to exit typing path" + } else { + insertExitText = "Press i to start typing path" + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + currentPath, + styles.BaseStyle().Width(adjustedWidth).Render(""), + styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)), + styles.BaseStyle().Width(adjustedWidth).Render(""), + styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText), + ) + + f.cwd.SetValue(f.cwd.Value()) + contentStyle := styles.BaseStyle().Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4) + + return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle) +} + +type FilepickerCmp interface { + tea.Model + ToggleFilepicker(showFilepicker bool) + IsCWDFocused() bool +} + +func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) { + f.ShowFilePicker = showFilepicker +} + +func (f *filepickerCmp) IsCWDFocused() bool { + return f.cwd.Focused() +} + +func NewFilepickerCmp(app *app.App) FilepickerCmp { + homepath, err := os.UserHomeDir() + if err != nil { + logging.Error("error loading user files") + return nil + } + baseDir := DirNode{parent: nil, directory: homepath} + dirs := readDir(homepath, false) + viewport := viewport.New(0, 0) + currentDirectory := textinput.New() + currentDirectory.CharLimit = 200 + currentDirectory.Width = 44 + currentDirectory.Cursor.Blink = true + currentDirectory.SetValue(baseDir.directory) + return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app} +} + +func (f *filepickerCmp) getCurrentFileBelowCursor() { + if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) { + logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor)) + f.viewport.SetContent("Preview unavailable") + return + } + + dir := f.dirs[f.cursor] + filename := dir.Name() + if !dir.IsDir() && isExtSupported(filename) { + fullPath := f.cwdDetails.directory + "/" + dir.Name() + + go func() { + imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath) + if err != nil { + logging.Error(err.Error()) + f.viewport.SetContent("Preview unavailable") + return + } + + f.viewport.SetContent(imageString) + }() + } else { + f.viewport.SetContent("Preview unavailable") + } +} + +func readDir(path string, showHidden bool) []os.DirEntry { + logging.Info(fmt.Sprintf("Reading directory: %s", path)) + + entriesChan := make(chan []os.DirEntry, 1) + errChan := make(chan error, 1) + + go func() { + dirEntries, err := os.ReadDir(path) + if err != nil { + logging.ErrorPersist(err.Error()) + errChan <- err + return + } + entriesChan <- dirEntries + }() + + select { + case dirEntries := <-entriesChan: + sort.Slice(dirEntries, func(i, j int) bool { + if dirEntries[i].IsDir() == dirEntries[j].IsDir() { + return dirEntries[i].Name() < dirEntries[j].Name() + } + return dirEntries[i].IsDir() + }) + + if showHidden { + return dirEntries + } + + var sanitizedDirEntries []os.DirEntry + for _, dirEntry := range dirEntries { + isHidden, _ := IsHidden(dirEntry.Name()) + if !isHidden { + if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) { + sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) + } + } + } + + return sanitizedDirEntries + + case err := <-errChan: + logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err) + return []os.DirEntry{} + + case <-time.After(5 * time.Second): + logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil) + return []os.DirEntry{} + } +} + +func IsHidden(file string) (bool, error) { + return strings.HasPrefix(file, "."), nil +} + +func isExtSupported(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png") +} diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go index 1f161c7d2..90959ad2e 100644 --- a/internal/tui/components/dialog/help.go +++ b/internal/tui/components/dialog/help.go @@ -74,7 +74,7 @@ func (h *helpCmp) render() string { var ( pairs []string width int - rows = 10 - 2 + rows = 12 - 2 ) for i := 0; i < len(bindings); i += rows { diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go index 48b7ce03f..77c2a02ac 100644 --- a/internal/tui/components/dialog/models.go +++ b/internal/tui/components/dialog/models.go @@ -270,20 +270,23 @@ func (m *modelDialogCmp) BindingKeys() []key.Binding { func (m *modelDialogCmp) setupModels() { cfg := config.Get() - + modelInfo := GetSelectedModel(cfg) m.availableProviders = getEnabledProviders(cfg) m.hScrollPossible = len(m.availableProviders) > 1 - agentCfg := cfg.Agents[config.AgentCoder] - selectedModelId := agentCfg.Model - modelInfo := models.SupportedModels[selectedModelId] - m.provider = modelInfo.Provider m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider) m.setupModelsForProvider(m.provider) } +func GetSelectedModel(cfg *config.Config) models.Model { + + agentCfg := cfg.Agents[config.AgentCoder] + selectedModelId := agentCfg.Model + return models.SupportedModels[selectedModelId] +} + func getEnabledProviders(cfg *config.Config) []models.ModelProvider { var providers []models.ModelProvider for providerId, provider := range cfg.Providers { diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go index fb12a2cd5..c8c34a570 100644 --- a/internal/tui/components/dialog/permission.go +++ b/internal/tui/components/dialog/permission.go @@ -2,8 +2,6 @@ package dialog import ( "fmt" - "strings" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -15,6 +13,7 @@ import ( "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" + "strings" ) type PermissionAction string diff --git a/internal/tui/image/images.go b/internal/tui/image/images.go new file mode 100644 index 000000000..d10a169fd --- /dev/null +++ b/internal/tui/image/images.go @@ -0,0 +1,72 @@ +package image + +import ( + "fmt" + "image" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/disintegration/imaging" + "github.com/lucasb-eyer/go-colorful" +) + +func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { + fileInfo, err := os.Stat(filePath) + if err != nil { + return false, fmt.Errorf("error getting file info: %w", err) + } + + if fileInfo.Size() > sizeLimit { + return true, nil + } + + return false, nil +} + +func ToString(width int, img image.Image) string { + img = imaging.Resize(img, width, 0, imaging.Lanczos) + b := img.Bounds() + imageWidth := b.Max.X + h := b.Max.Y + str := strings.Builder{} + + for heightCounter := 0; heightCounter < h; heightCounter += 2 { + for x := range imageWidth { + c1, _ := colorful.MakeColor(img.At(x, heightCounter)) + color1 := lipgloss.Color(c1.Hex()) + + var color2 lipgloss.Color + if heightCounter+1 < h { + c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) + color2 = lipgloss.Color(c2.Hex()) + } else { + color2 = color1 + } + + str.WriteString(lipgloss.NewStyle().Foreground(color1). + Background(color2).Render("▀")) + } + + str.WriteString("\n") + } + + return str.String() +} + +func ImagePreview(width int, filename string) (string, error) { + imageContent, err := os.Open(filename) + if err != nil { + return "", err + } + defer imageContent.Close() + + img, _, err := image.Decode(imageContent) + if err != nil { + return "", err + } + + imageString := ToString(width, img) + + return imageString, nil +} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 024974e5c..740aaabca 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "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/chat" "github.com/opencode-ai/opencode/internal/tui/layout" @@ -54,7 +55,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := p.layout.SetSize(msg.Width, msg.Height) cmds = append(cmds, cmd) case chat.SendMsg: - cmd := p.sendMessage(msg.Text) + cmd := p.sendMessage(msg.Text, msg.Attachments) if cmd != nil { return p, cmd } @@ -117,7 +118,7 @@ func (p *chatPage) clearSidebar() tea.Cmd { return p.layout.ClearRightPanel() } -func (p *chatPage) sendMessage(text string) tea.Cmd { +func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { var cmds []tea.Cmd if p.session.ID == "" { session, err := p.app.Sessions.Create(context.Background(), "New Session") @@ -133,7 +134,10 @@ func (p *chatPage) sendMessage(text string) tea.Cmd { cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) } - p.app.CoderAgent.Run(context.Background(), p.session.ID, text) + _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...) + if err != nil { + return util.ReportError(err) + } return tea.Batch(cmds...) } @@ -152,6 +156,7 @@ func (p *chatPage) View() string { func (p *chatPage) BindingKeys() []key.Binding { bindings := layout.KeyMapToSlice(keyMap) bindings = append(bindings, p.messages.BindingKeys()...) + bindings = append(bindings, p.editor.BindingKeys()...) return bindings } diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index 8a49fce80..684ca7152 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -3,11 +3,12 @@ package styles const ( OpenCodeIcon string = "ⓒ" - ErrorIcon string = "ⓧ" - WarningIcon string = "ⓦ" - InfoIcon string = "ⓘ" - HintIcon string = "ⓗ" - SpinnerIcon string = "⟳" + ErrorIcon string = "ⓧ" + WarningIcon string = "ⓦ" + InfoIcon string = "ⓘ" + HintIcon string = "ⓗ" + SpinnerIcon string = "⟳" + DocumentIcon string = "🖼" ) // CircledDigit returns the Unicode circled digit/number for 0‑20. diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index b2d83a523..b70aec4c7 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -5,6 +5,10 @@ import ( "github.com/opencode-ai/opencode/internal/tui/theme" ) +var ( + ImageBakcground = "#212121" +) + // Style generation functions that use the current theme // BaseStyle returns the base style with background and foreground colors diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 278252ab9..62d379c3f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -26,10 +26,15 @@ type keyMap struct { Help key.Binding SwitchSession key.Binding Commands key.Binding + Filepicker key.Binding Models key.Binding SwitchTheme key.Binding } +const ( + quitKey = "q" +) + var keys = keyMap{ Logs: key.NewBinding( key.WithKeys("ctrl+l"), @@ -54,7 +59,10 @@ var keys = keyMap{ key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands"), ), - + Filepicker: key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "select files to upload"), + ), Models: key.NewBinding( key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "model selection"), @@ -77,7 +85,7 @@ var returnKey = key.NewBinding( ) var logsKeyReturnKey = key.NewBinding( - key.WithKeys("esc", "backspace", "q"), + key.WithKeys("esc", "backspace", quitKey), key.WithHelp("esc/q", "go back"), ) @@ -112,6 +120,9 @@ type appModel struct { showInitDialog bool initDialog dialog.InitDialogCmp + showFilepicker bool + filepicker dialog.FilepickerCmp + showThemeDialog bool themeDialog dialog.ThemeDialog } @@ -135,6 +146,7 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, cmd) cmd = a.initDialog.Init() cmds = append(cmds, cmd) + cmd = a.filepicker.Init() cmd = a.themeDialog.Init() cmds = append(cmds, cmd) @@ -182,6 +194,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.commandDialog = command.(dialog.CommandDialog) cmds = append(cmds, commandCmd) + filepicker, filepickerCmd := a.filepicker.Update(msg) + a.filepicker = filepicker.(dialog.FilepickerCmp) + cmds = append(cmds, filepickerCmd) + a.initDialog.SetSize(msg.Width, msg.Height) return a, tea.Batch(cmds...) @@ -333,6 +349,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch { + case key.Matches(msg, keys.Quit): a.showQuit = !a.showQuit if a.showHelp { @@ -344,6 +361,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showCommandDialog { a.showCommandDialog = false } + if a.showFilepicker { + a.showFilepicker = false + a.filepicker.ToggleFilepicker(a.showFilepicker) + } if a.showModelDialog { a.showModelDialog = false } @@ -364,7 +385,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil case key.Matches(msg, keys.Commands): - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog { + if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { // Show commands dialog if len(a.commands) == 0 { return a, util.ReportWarn("No commands available") @@ -390,26 +411,36 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.themeDialog.Init() } return a, nil - case key.Matches(msg, logsKeyReturnKey): - if a.currentPage == page.LogsPage { - return a, a.moveToPage(page.ChatPage) - } - case key.Matches(msg, returnKey): - if a.showQuit { - a.showQuit = !a.showQuit - return a, nil - } - if a.showHelp { - a.showHelp = !a.showHelp - return a, nil - } - if a.showInitDialog { - a.showInitDialog = false - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) + case key.Matches(msg, returnKey) || key.Matches(msg): + if msg.String() == quitKey { + if a.currentPage == page.LogsPage { + return a, a.moveToPage(page.ChatPage) + } + } else if !a.filepicker.IsCWDFocused() { + if a.showQuit { + a.showQuit = !a.showQuit + return a, nil + } + if a.showHelp { + a.showHelp = !a.showHelp + return a, nil + } + if a.showInitDialog { + a.showInitDialog = false + // Mark the project as initialized without running the command + if err := config.MarkProjectInitialized(); err != nil { + return a, util.ReportError(err) + } + return a, nil + } + if a.showFilepicker { + a.showFilepicker = false + a.filepicker.ToggleFilepicker(a.showFilepicker) + return a, nil + } + if a.currentPage == page.LogsPage { + return a, a.moveToPage(page.ChatPage) } - return a, nil } case key.Matches(msg, keys.Logs): return a, a.moveToPage(page.LogsPage) @@ -427,10 +458,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showHelp = !a.showHelp return a, nil } + case key.Matches(msg, keys.Filepicker): + a.showFilepicker = !a.showFilepicker + a.filepicker.ToggleFilepicker(a.showFilepicker) + return a, nil } + default: + f, filepickerCmd := a.filepicker.Update(msg) + a.filepicker = f.(dialog.FilepickerCmp) + cmds = append(cmds, filepickerCmd) } + if a.showFilepicker { + f, filepickerCmd := a.filepicker.Update(msg) + a.filepicker = f.(dialog.FilepickerCmp) + cmds = append(cmds, filepickerCmd) + // Only block key messages send all other messages down + if _, ok := msg.(tea.KeyMsg); ok { + return a, tea.Batch(cmds...) + } + } + if a.showQuit { q, quitCmd := a.quit.Update(msg) a.quit = q.(dialog.QuitDialog) @@ -517,6 +566,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { // For now we don't move to any page if the agent is busy return util.ReportWarn("Agent is busy, please wait...") } + var cmds []tea.Cmd if _, ok := a.loadedPages[pageID]; !ok { cmd := a.pages[pageID].Init() @@ -557,6 +607,22 @@ func (a appModel) View() string { ) } + if a.showFilepicker { + overlay := a.filepicker.View() + row := lipgloss.Height(appView) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(appView) / 2 + col -= lipgloss.Width(overlay) / 2 + appView = layout.PlaceOverlay( + col, + row, + overlay, + appView, + true, + ) + + } + if !a.app.CoderAgent.IsBusy() { a.status.SetHelpWidgetMsg("ctrl+? help") } else { @@ -702,6 +768,7 @@ func New(app *app.App) tea.Model { page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), }, + filepicker: dialog.NewFilepickerCmp(app), } model.RegisterCommand(dialog.Command{ |
