summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-08 08:08:53 -0500
committeradamdottv <[email protected]>2025-07-08 08:09:01 -0500
commit662d022a4859ee1c004133559ee42c5f7044dda7 (patch)
tree304b17deceb254d0d70d29e9d46e865b1c4868cf /packages/tui/internal
parent9efef03919f99750277a7f15722c24d7c7958224 (diff)
downloadopencode-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.go12
-rw-r--r--packages/tui/internal/commands/command.go2
-rw-r--r--packages/tui/internal/components/chat/editor.go93
-rw-r--r--packages/tui/internal/components/textarea/textarea.go26
-rw-r--r--packages/tui/internal/image/clipboard_unix.go46
-rw-r--r--packages/tui/internal/image/clipboard_windows.go192
-rw-r--r--packages/tui/internal/image/images.go86
-rw-r--r--packages/tui/internal/tui/tui.go4
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)