summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorphantomreactor <[email protected]>2025-05-17 01:01:50 +0530
committerGitHub <[email protected]>2025-05-16 14:31:50 -0500
commitba416e787b651ea045ff955eb32c0e7109a169e8 (patch)
tree59133f1dca814bf11a183d9f59bd52e09c93a737
parentb71cae63f1b59cc3f095912d040b915312d144ff (diff)
downloadopencode-ba416e787b651ea045ff955eb32c0e7109a169e8.tar.gz
opencode-ba416e787b651ea045ff955eb32c0e7109a169e8.zip
paste images with ctrl+v (#26)
-rw-r--r--go.mod4
-rw-r--r--internal/tui/components/chat/editor.go23
-rw-r--r--internal/tui/image/clipboard_unix.go49
-rw-r--r--internal/tui/image/clipboard_windows.go192
-rw-r--r--internal/tui/image/images.go12
5 files changed, 278 insertions, 2 deletions
diff --git a/go.mod b/go.mod
index 777ba525b..7136d8784 100644
--- a/go.mod
+++ b/go.mod
@@ -42,7 +42,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
- github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/atotto/clipboard v0.1.4
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
@@ -115,7 +115,7 @@ require (
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
- golang.org/x/image v0.26.0 // indirect
+ golang.org/x/image v0.26.0
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index 4d5ba0128..607aaedf3 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -2,6 +2,7 @@ package chat
import (
"fmt"
+ "log/slog"
"os"
"os/exec"
"slices"
@@ -16,6 +17,7 @@ import (
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
+ "github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
@@ -34,6 +36,7 @@ type editorCmp struct {
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
+ Paste key.Binding
}
type bluredEditorKeyMaps struct {
@@ -56,6 +59,10 @@ var editorMaps = EditorKeyMaps{
key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "open editor"),
),
+ Paste: key.NewBinding(
+ key.WithKeys("ctrl+v"),
+ key.WithHelp("ctrl+v", "paste content"),
+ ),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
@@ -200,6 +207,22 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.deleteMode = false
return m, nil
}
+
+ if key.Matches(msg, editorMaps.Paste) {
+ imageBytes, text, err := image.GetImageFromClipboard()
+ if err != nil {
+ slog.Error(err.Error())
+ return m, cmd
+ }
+ if len(imageBytes) != 0 {
+ attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
+ attachment := message.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
+ m.attachments = append(m.attachments, attachment)
+ } else {
+ m.textarea.SetValue(m.textarea.Value() + text)
+ }
+ return m, cmd
+ }
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
diff --git a/internal/tui/image/clipboard_unix.go b/internal/tui/image/clipboard_unix.go
new file mode 100644
index 000000000..3cb590207
--- /dev/null
+++ b/internal/tui/image/clipboard_unix.go
@@ -0,0 +1,49 @@
+//go:build !windows
+
+package image
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "github.com/atotto/clipboard"
+)
+
+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/internal/tui/image/clipboard_windows.go b/internal/tui/image/clipboard_windows.go
new file mode 100644
index 000000000..6431ce3d4
--- /dev/null
+++ b/internal/tui/image/clipboard_windows.go
@@ -0,0 +1,192 @@
+//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/internal/tui/image/images.go b/internal/tui/image/images.go
index b55884d11..f476b201c 100644
--- a/internal/tui/image/images.go
+++ b/internal/tui/image/images.go
@@ -1,8 +1,10 @@
package image
import (
+ "bytes"
"fmt"
"image"
+ "image/png"
"os"
"strings"
@@ -71,3 +73,13 @@ func ImagePreview(width int, filename string) (string, error) {
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
+}