phantomreactor 9 месяцев назад
Родитель
Сommit
ba416e787b

+ 2 - 2
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

+ 23 - 0
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()

+ 49 - 0
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
+}

+ 192 - 0
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])
+}

+ 12 - 0
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
+}