Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/tui/components/chat/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package chat

import (
"fmt"
"log/slog"
"os"
"os/exec"
"slices"
Expand All @@ -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"
Expand All @@ -34,6 +36,7 @@ type editorCmp struct {
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
}

type bluredEditorKeyMaps struct {
Expand All @@ -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{
Expand Down Expand Up @@ -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()
Expand Down
49 changes: 49 additions & 0 deletions internal/tui/image/clipboard_unix.go
Original file line number Diff line number Diff line change
@@ -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 changes: 192 additions & 0 deletions internal/tui/image/clipboard_windows.go
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions internal/tui/image/images.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package image

import (
"bytes"
"fmt"
"image"
"image/png"
"os"
"strings"

Expand Down Expand Up @@ -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
}