feat: add support for images

This commit is contained in:
phantomreactor
2025-05-03 01:53:58 +05:30
committed by adamdottv
parent 0095832be3
commit ff0ef3bb43
30 changed files with 1323 additions and 468 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 020.

View File

@@ -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

View File

@@ -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{