diff options
| author | adamdottv <[email protected]> | 2025-07-08 08:08:53 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-07-08 08:09:01 -0500 |
| commit | 662d022a4859ee1c004133559ee42c5f7044dda7 (patch) | |
| tree | 304b17deceb254d0d70d29e9d46e865b1c4868cf /packages/tui/internal | |
| parent | 9efef03919f99750277a7f15722c24d7c7958224 (diff) | |
| download | opencode-662d022a4859ee1c004133559ee42c5f7044dda7.tar.gz opencode-662d022a4859ee1c004133559ee42c5f7044dda7.zip | |
feat(tui): paste images and pdfs
Diffstat (limited to 'packages/tui/internal')
| -rw-r--r-- | packages/tui/internal/app/app.go | 12 | ||||
| -rw-r--r-- | packages/tui/internal/commands/command.go | 2 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 93 | ||||
| -rw-r--r-- | packages/tui/internal/components/textarea/textarea.go | 26 | ||||
| -rw-r--r-- | packages/tui/internal/image/clipboard_unix.go | 46 | ||||
| -rw-r--r-- | packages/tui/internal/image/clipboard_windows.go | 192 | ||||
| -rw-r--r-- | packages/tui/internal/image/images.go | 86 | ||||
| -rw-r--r-- | packages/tui/internal/tui/tui.go | 4 |
8 files changed, 97 insertions, 364 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 08a999f14..237ee0fee 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -18,6 +18,7 @@ import ( "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" + "golang.design/x/clipboard" ) type App struct { @@ -146,6 +147,17 @@ func (a *App) Key(commandName commands.CommandName) string { return base(key) + muted(" "+command.Description) } +func (a *App) SetClipboard(text string) tea.Cmd { + var cmds []tea.Cmd + cmds = append(cmds, func() tea.Msg { + clipboard.Write(clipboard.FmtText, []byte(text)) + return nil + }) + // try to set the clipboard using OSC52 for terminals that support it + cmds = append(cmds, tea.SetClipboard(text)) + return tea.Sequence(cmds...) +} + func (a *App) InitializeProvider() tea.Cmd { providersResponse, err := a.Client.Config.Providers(context.Background()) if err != nil { diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 9c4da12ec..10b0d7e27 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -231,7 +231,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { { Name: InputPasteCommand, Description: "paste content", - Keybindings: parseBindings("ctrl+v"), + Keybindings: parseBindings("ctrl+v", "super+v"), }, { Name: InputSubmitCommand, diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index f32e5c32a..cc31fbef2 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -1,9 +1,12 @@ package chat import ( + "encoding/base64" "fmt" "log/slog" + "os" "path/filepath" + "strconv" "strings" "github.com/charmbracelet/bubbles/v2/spinner" @@ -15,10 +18,10 @@ import ( "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/textarea" - "github.com/sst/opencode/internal/image" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" + "golang.design/x/clipboard" ) type EditorComponent interface { @@ -63,6 +66,57 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } + case tea.PasteMsg: + text := string(msg) + text = strings.ReplaceAll(text, "\\", "") + text, err := strconv.Unquote(`"` + text + `"`) + if err != nil { + slog.Error("Failed to unquote text", "error", err) + m.textarea.InsertRunesFromUserInput([]rune(msg)) + return m, nil + } + if _, err := os.Stat(text); err != nil { + slog.Error("Failed to paste file", "error", err) + m.textarea.InsertRunesFromUserInput([]rune(msg)) + return m, nil + } + + filePath := text + ext := strings.ToLower(filepath.Ext(filePath)) + + mediaType := "" + switch ext { + case ".jpg": + mediaType = "image/jpeg" + case ".png", ".jpeg", ".gif", ".webp": + mediaType = "image/" + ext[1:] + case ".pdf": + mediaType = "application/pdf" + default: + mediaType = "text/plain" + } + + fileBytes, err := os.ReadFile(filePath) + if err != nil { + slog.Error("Failed to read file", "error", err) + m.textarea.InsertRunesFromUserInput([]rune(msg)) + return m, nil + } + base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes) + url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile) + + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: fmt.Sprintf("<%s>", filePath), + URL: url, + Filename: filePath, + MediaType: mediaType, + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") + case tea.ClipboardMsg: + text := string(msg) + m.textarea.InsertRunesFromUserInput([]rune(text)) case dialog.ThemeSelectedMsg: m.textarea = m.resetTextareaStyles() m.spinner = createSpinner() @@ -269,24 +323,29 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { } func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - _, text, err := image.GetImageFromClipboard() - if err != nil { - slog.Error(err.Error()) + imageBytes := clipboard.Read(clipboard.FmtImage) + if imageBytes != nil { + base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes) + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "<clipboard-image>", + Filename: "clipboard-image", + MediaType: "image/png", + URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile), + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") return m, nil } - // if len(imageBytes) != 0 { - // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) - // attachment := app.Attachment{ - // FilePath: attachmentName, - // FileName: attachmentName, - // Content: imageBytes, - // MimeType: "image/png", - // } - // m.attachments = append(m.attachments, attachment) - // } else { - m.textarea.InsertString(text) - // } - return m, nil + + textBytes := clipboard.Read(clipboard.FmtText) + if textBytes != nil { + m.textarea.InsertRunesFromUserInput([]rune(string(textBytes))) + return m, nil + } + + // fallback to reading the clipboard using OSC52 + return m, tea.ReadClipboard } func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 5ff936f17..41a4a3d91 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -11,7 +11,6 @@ import ( "slices" - "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -653,12 +652,12 @@ func (m *Model) SetValue(s string) { // InsertString inserts a string at the cursor position. func (m *Model) InsertString(s string) { - m.insertRunesFromUserInput([]rune(s)) + m.InsertRunesFromUserInput([]rune(s)) } // InsertRune inserts a rune at the cursor position. func (m *Model) InsertRune(r rune) { - m.insertRunesFromUserInput([]rune{r}) + m.InsertRunesFromUserInput([]rune{r}) } // InsertAttachment inserts an attachment at the cursor position. @@ -730,8 +729,8 @@ func (m Model) GetAttachments() []*Attachment { return attachments } -// insertRunesFromUserInput inserts runes at the current cursor position. -func (m *Model) insertRunesFromUserInput(runes []rune) { +// InsertRunesFromUserInput inserts runes at the current cursor position. +func (m *Model) InsertRunesFromUserInput(runes []rune) { // Clean up any special characters in the input provided by the // clipboard. This avoids bugs due to e.g. tab characters and // whatnot. @@ -1429,8 +1428,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } switch msg := msg.(type) { - case tea.PasteMsg: - m.insertRunesFromUserInput([]rune(msg)) case tea.KeyPressMsg: switch { case key.Matches(msg, m.KeyMap.DeleteAfterCursor): @@ -1490,8 +1487,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.CursorDown() case key.Matches(msg, m.KeyMap.WordForward): m.wordRight() - case key.Matches(msg, m.KeyMap.Paste): - return m, Paste case key.Matches(msg, m.KeyMap.CharacterBackward): m.characterLeft(false /* insideLine */) case key.Matches(msg, m.KeyMap.LinePrevious): @@ -1512,11 +1507,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune(msg.Text)) + m.InsertRunesFromUserInput([]rune(msg.Text)) } case pasteMsg: - m.insertRunesFromUserInput([]rune(msg)) + m.InsertRunesFromUserInput([]rune(msg)) case pasteErrMsg: m.Err = msg @@ -1908,15 +1903,6 @@ func (m *Model) splitLine(row, col int) { m.row++ } -// Paste is a command for pasting from the clipboard into the text input. -func Paste() tea.Msg { - str, err := clipboard.ReadAll() - if err != nil { - return pasteErrMsg{err} - } - return pasteMsg(str) -} - func wrapInterfaces(content []any, width int) [][]any { if width <= 0 { return [][]any{content} diff --git a/packages/tui/internal/image/clipboard_unix.go b/packages/tui/internal/image/clipboard_unix.go deleted file mode 100644 index 2653d8cad..000000000 --- a/packages/tui/internal/image/clipboard_unix.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build !windows - -package image - -import ( - "bytes" - "fmt" - "github.com/atotto/clipboard" - "image" -) - -func GetImageFromClipboard() ([]byte, string, error) { - text, err := clipboard.ReadAll() - if err != nil { - return nil, "", fmt.Errorf("Error reading clipboard") - } - - if text == "" { - return nil, "", nil - } - - binaryData := []byte(text) - imageBytes, err := binaryToImage(binaryData) - if err != nil { - return nil, text, nil - } - return imageBytes, "", nil - -} - -func binaryToImage(data []byte) ([]byte, error) { - reader := bytes.NewReader(data) - img, _, err := image.Decode(reader) - if err != nil { - return nil, fmt.Errorf("Unable to covert bytes to image") - } - - return ImageToBytes(img) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/packages/tui/internal/image/clipboard_windows.go b/packages/tui/internal/image/clipboard_windows.go deleted file mode 100644 index 6431ce3d4..000000000 --- a/packages/tui/internal/image/clipboard_windows.go +++ /dev/null @@ -1,192 +0,0 @@ -//go:build windows - -package image - -import ( - "bytes" - "fmt" - "image" - "image/color" - "log/slog" - "syscall" - "unsafe" -) - -var ( - user32 = syscall.NewLazyDLL("user32.dll") - kernel32 = syscall.NewLazyDLL("kernel32.dll") - openClipboard = user32.NewProc("OpenClipboard") - closeClipboard = user32.NewProc("CloseClipboard") - getClipboardData = user32.NewProc("GetClipboardData") - isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable") - globalLock = kernel32.NewProc("GlobalLock") - globalUnlock = kernel32.NewProc("GlobalUnlock") - globalSize = kernel32.NewProc("GlobalSize") -) - -const ( - CF_TEXT = 1 - CF_UNICODETEXT = 13 - CF_DIB = 8 -) - -type BITMAPINFOHEADER struct { - BiSize uint32 - BiWidth int32 - BiHeight int32 - BiPlanes uint16 - BiBitCount uint16 - BiCompression uint32 - BiSizeImage uint32 - BiXPelsPerMeter int32 - BiYPelsPerMeter int32 - BiClrUsed uint32 - BiClrImportant uint32 -} - -func GetImageFromClipboard() ([]byte, string, error) { - ret, _, _ := openClipboard.Call(0) - if ret == 0 { - return nil, "", fmt.Errorf("failed to open clipboard") - } - defer func(closeClipboard *syscall.LazyProc, a ...uintptr) { - _, _, err := closeClipboard.Call(a...) - if err != nil { - slog.Error("close clipboard failed") - return - } - }(closeClipboard) - isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT)) - isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT)) - - if isTextAvailable != 0 || isUnicodeTextAvailable != 0 { - // Get text from clipboard - var formatToUse uintptr = CF_TEXT - if isUnicodeTextAvailable != 0 { - formatToUse = CF_UNICODETEXT - } - - hClipboardText, _, _ := getClipboardData.Call(formatToUse) - if hClipboardText != 0 { - textPtr, _, _ := globalLock.Call(hClipboardText) - if textPtr != 0 { - defer func(globalUnlock *syscall.LazyProc, a ...uintptr) { - _, _, err := globalUnlock.Call(a...) - if err != nil { - slog.Error("Global unlock failed") - return - } - }(globalUnlock, hClipboardText) - - // Get clipboard text - var clipboardText string - if formatToUse == CF_UNICODETEXT { - // Convert wide string to Go string - clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:]) - } else { - // Get size of ANSI text - size, _, _ := globalSize.Call(hClipboardText) - if size > 0 { - // Convert ANSI string to Go string - textBytes := make([]byte, size) - copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size]) - clipboardText = bytesToString(textBytes) - } - } - - // Check if the text is not empty - if clipboardText != "" { - return nil, clipboardText, nil - } - } - } - } - hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB)) - if hClipboardData == 0 { - return nil, "", fmt.Errorf("failed to get clipboard data") - } - - dataPtr, _, _ := globalLock.Call(hClipboardData) - if dataPtr == 0 { - return nil, "", fmt.Errorf("failed to lock clipboard data") - } - defer func(globalUnlock *syscall.LazyProc, a ...uintptr) { - _, _, err := globalUnlock.Call(a...) - if err != nil { - slog.Error("Global unlock failed") - return - } - }(globalUnlock, hClipboardData) - - bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr)) - - width := int(bmiHeader.BiWidth) - height := int(bmiHeader.BiHeight) - if height < 0 { - height = -height - } - bitsPerPixel := int(bmiHeader.BiBitCount) - - img := image.NewRGBA(image.Rect(0, 0, width, height)) - - var bitsOffset uintptr - if bitsPerPixel <= 8 { - numColors := uint32(1) << bitsPerPixel - if bmiHeader.BiClrUsed > 0 { - numColors = bmiHeader.BiClrUsed - } - bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4) - } else { - bitsOffset = unsafe.Sizeof(*bmiHeader) - } - - for y := range height { - for x := range width { - - srcY := height - y - 1 - if bmiHeader.BiHeight < 0 { - srcY = y - } - - var pixelPointer unsafe.Pointer - var r, g, b, a uint8 - - switch bitsPerPixel { - case 24: - stride := (width*3 + 3) &^ 3 - pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3)) - b = *(*byte)(pixelPointer) - g = *(*byte)(unsafe.Add(pixelPointer, 1)) - r = *(*byte)(unsafe.Add(pixelPointer, 2)) - a = 255 - case 32: - pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4)) - b = *(*byte)(pixelPointer) - g = *(*byte)(unsafe.Add(pixelPointer, 1)) - r = *(*byte)(unsafe.Add(pixelPointer, 2)) - a = *(*byte)(unsafe.Add(pixelPointer, 3)) - if a == 0 { - a = 255 - } - default: - return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel) - } - - img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a}) - } - } - - imageBytes, err := ImageToBytes(img) - if err != nil { - return nil, "", err - } - return imageBytes, "", nil -} - -func bytesToString(b []byte) string { - i := bytes.IndexByte(b, 0) - if i == -1 { - return string(b) - } - return string(b[:i]) -} diff --git a/packages/tui/internal/image/images.go b/packages/tui/internal/image/images.go deleted file mode 100644 index 742eb30a8..000000000 --- a/packages/tui/internal/image/images.go +++ /dev/null @@ -1,86 +0,0 @@ -package image - -import ( - "bytes" - "fmt" - "image" - "image/color" - "image/png" - "os" - "strings" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/disintegration/imaging" - "github.com/lucasb-eyer/go-colorful" - _ "golang.org/x/image/webp" -) - -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 color.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 -} - -func ImageToBytes(image image.Image) ([]byte, error) { - buf := new(bytes.Buffer) - err := png.Encode(buf, image) - if err != nil { - return nil, err - } - - return buf.Bytes(), nil -} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index e32575bd4..d25fc1395 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -841,7 +841,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return a, toast.NewErrorToast("Failed to share session") } shareUrl := response.Share.URL - cmds = append(cmds, tea.SetClipboard(shareUrl)) + cmds = append(cmds, a.app.SetClipboard(shareUrl)) cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!")) case commands.SessionUnshareCommand: if a.app.Session.ID == "" { @@ -975,7 +975,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) case commands.MessagesCopyCommand: selected := a.messages.Selected() if selected != "" { - cmd = tea.SetClipboard(selected) + cmd = a.app.SetClipboard(selected) cmds = append(cmds, cmd) cmd = toast.NewSuccessToast("Message copied to clipboard") cmds = append(cmds, cmd) |
