summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-09 04:55:19 -0500
committeradamdottv <[email protected]>2025-07-09 04:55:24 -0500
commit3f25e5bf869d70a06afab64ca7812930f06e4ed5 (patch)
tree0a2aaf39ec4bfb945d62ab3becd1918f1f4a9dca /packages/tui/internal
parent67765fa47c54c0d0b8146fb124c0d412e09bf5e8 (diff)
downloadopencode-3f25e5bf869d70a06afab64ca7812930f06e4ed5.tar.gz
opencode-3f25e5bf869d70a06afab64ca7812930f06e4ed5.zip
chore: internal clipboard package
Diffstat (limited to 'packages/tui/internal')
-rw-r--r--packages/tui/internal/app/app.go2
-rw-r--r--packages/tui/internal/clipboard/clipboard.go155
-rw-r--r--packages/tui/internal/clipboard/clipboard_darwin.go266
-rw-r--r--packages/tui/internal/clipboard/clipboard_linux.go276
-rw-r--r--packages/tui/internal/clipboard/clipboard_nocgo.go25
-rw-r--r--packages/tui/internal/clipboard/clipboard_windows.go551
-rw-r--r--packages/tui/internal/components/chat/editor.go2
7 files changed, 1275 insertions, 2 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 83934343e..a70b3ca64 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -12,13 +12,13 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
+ "github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"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 {
diff --git a/packages/tui/internal/clipboard/clipboard.go b/packages/tui/internal/clipboard/clipboard.go
new file mode 100644
index 000000000..70e05bd29
--- /dev/null
+++ b/packages/tui/internal/clipboard/clipboard.go
@@ -0,0 +1,155 @@
+// Copyright 2021 The golang.design Initiative Authors.
+// All rights reserved. Use of this source code is governed
+// by a MIT license that can be found in the LICENSE file.
+//
+// Written by Changkun Ou <changkun.de>
+
+/*
+Package clipboard provides cross platform clipboard access and supports
+macOS/Linux/Windows/Android/iOS platform. Before interacting with the
+clipboard, one must call Init to assert if it is possible to use this
+package:
+
+ err := clipboard.Init()
+ if err != nil {
+ panic(err)
+ }
+
+The most common operations are `Read` and `Write`. To use them:
+
+ // write/read text format data of the clipboard, and
+ // the byte buffer regarding the text are UTF8 encoded.
+ clipboard.Write(clipboard.FmtText, []byte("text data"))
+ clipboard.Read(clipboard.FmtText)
+
+ // write/read image format data of the clipboard, and
+ // the byte buffer regarding the image are PNG encoded.
+ clipboard.Write(clipboard.FmtImage, []byte("image data"))
+ clipboard.Read(clipboard.FmtImage)
+
+Note that read/write regarding image format assumes that the bytes are
+PNG encoded since it serves the alpha blending purpose that might be
+used in other graphical software.
+
+In addition, `clipboard.Write` returns a channel that can receive an
+empty struct as a signal, which indicates the corresponding write call
+to the clipboard is outdated, meaning the clipboard has been overwritten
+by others and the previously written data is lost. For instance:
+
+ changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
+
+ select {
+ case <-changed:
+ println(`"text data" is no longer available from clipboard.`)
+ }
+
+You can ignore the returning channel if you don't need this type of
+notification. Furthermore, when you need more than just knowing whether
+clipboard data is changed, use the watcher API:
+
+ ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
+ for data := range ch {
+ // print out clipboard data whenever it is changed
+ println(string(data))
+ }
+*/
+package clipboard
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "sync"
+)
+
+var (
+ // activate only for running tests.
+ debug = false
+ errUnavailable = errors.New("clipboard unavailable")
+ errUnsupported = errors.New("unsupported format")
+ errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
+)
+
+// Format represents the format of clipboard data.
+type Format int
+
+// All sorts of supported clipboard data
+const (
+ // FmtText indicates plain text clipboard format
+ FmtText Format = iota
+ // FmtImage indicates image/png clipboard format
+ FmtImage
+)
+
+var (
+ // Due to the limitation on operating systems (such as darwin),
+ // concurrent read can even cause panic, use a global lock to
+ // guarantee one read at a time.
+ lock = sync.Mutex{}
+ initOnce sync.Once
+ initError error
+)
+
+// Init initializes the clipboard package. It returns an error
+// if the clipboard is not available to use. This may happen if the
+// target system lacks required dependency, such as libx11-dev in X11
+// environment. For example,
+//
+// err := clipboard.Init()
+// if err != nil {
+// panic(err)
+// }
+//
+// If Init returns an error, any subsequent Read/Write/Watch call
+// may result in an unrecoverable panic.
+func Init() error {
+ initOnce.Do(func() {
+ initError = initialize()
+ })
+ return initError
+}
+
+// Read returns a chunk of bytes of the clipboard data if it presents
+// in the desired format t presents. Otherwise, it returns nil.
+func Read(t Format) []byte {
+ lock.Lock()
+ defer lock.Unlock()
+
+ buf, err := read(t)
+ if err != nil {
+ if debug {
+ fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
+ }
+ return nil
+ }
+ return buf
+}
+
+// Write writes a given buffer to the clipboard in a specified format.
+// Write returned a receive-only channel can receive an empty struct
+// as a signal, which indicates the clipboard has been overwritten from
+// this write.
+// If format t indicates an image, then the given buf assumes
+// the image data is PNG encoded.
+func Write(t Format, buf []byte) <-chan struct{} {
+ lock.Lock()
+ defer lock.Unlock()
+
+ changed, err := write(t, buf)
+ if err != nil {
+ if debug {
+ fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
+ }
+ return nil
+ }
+ return changed
+}
+
+// Watch returns a receive-only channel that received the clipboard data
+// whenever any change of clipboard data in the desired format happens.
+//
+// The returned channel will be closed if the given context is canceled.
+func Watch(ctx context.Context, t Format) <-chan []byte {
+ return watch(ctx, t)
+}
diff --git a/packages/tui/internal/clipboard/clipboard_darwin.go b/packages/tui/internal/clipboard/clipboard_darwin.go
new file mode 100644
index 000000000..ead6811f1
--- /dev/null
+++ b/packages/tui/internal/clipboard/clipboard_darwin.go
@@ -0,0 +1,266 @@
+// Copyright 2021 The golang.design Initiative Authors.
+// All rights reserved. Use of this source code is governed
+// by a MIT license that can be found in the LICENSE file.
+//
+// Written by Changkun Ou <changkun.de>
+
+//go:build darwin
+
+package clipboard
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ lastChangeCount int64
+ changeCountMu sync.Mutex
+)
+
+func initialize() error { return nil }
+
+func read(t Format) (buf []byte, err error) {
+ switch t {
+ case FmtText:
+ return readText()
+ case FmtImage:
+ return readImage()
+ default:
+ return nil, errUnsupported
+ }
+}
+
+func readText() ([]byte, error) {
+ // Check if clipboard contains string data
+ checkScript := `
+ try
+ set clipboardTypes to (clipboard info)
+ repeat with aType in clipboardTypes
+ if (first item of aType) is string then
+ return "hastext"
+ end if
+ end repeat
+ return "notext"
+ on error
+ return "error"
+ end try
+ `
+
+ cmd := exec.Command("osascript", "-e", checkScript)
+ checkOut, err := cmd.Output()
+ if err != nil {
+ return nil, errUnavailable
+ }
+
+ checkOut = bytes.TrimSpace(checkOut)
+ if !bytes.Equal(checkOut, []byte("hastext")) {
+ return nil, errUnavailable
+ }
+
+ // Now get the actual text
+ cmd = exec.Command("osascript", "-e", "get the clipboard")
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, errUnavailable
+ }
+ // Remove trailing newline that osascript adds
+ out = bytes.TrimSuffix(out, []byte("\n"))
+
+ // If clipboard was set to empty string, return nil
+ if len(out) == 0 {
+ return nil, nil
+ }
+ return out, nil
+}
+func readImage() ([]byte, error) {
+ // AppleScript to read image data from clipboard as base64
+ script := `
+ try
+ set theData to the clipboard as «class PNGf»
+ return theData
+ on error
+ return ""
+ end try
+ `
+
+ cmd := exec.Command("osascript", "-e", script)
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, errUnavailable
+ }
+
+ // Check if we got any data
+ out = bytes.TrimSpace(out)
+ if len(out) == 0 {
+ return nil, errUnavailable
+ }
+
+ // The output is in hex format (e.g., «data PNGf89504E...»)
+ // We need to extract and convert it
+ outStr := string(out)
+ if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
+ return nil, errUnavailable
+ }
+
+ // Extract hex data
+ hexData := strings.TrimPrefix(outStr, "«data PNGf")
+ hexData = strings.TrimSuffix(hexData, "»")
+
+ // Convert hex to bytes
+ buf := make([]byte, len(hexData)/2)
+ for i := 0; i < len(hexData); i += 2 {
+ b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
+ if err != nil {
+ return nil, errUnavailable
+ }
+ buf[i/2] = byte(b)
+ }
+
+ return buf, nil
+}
+
+// write writes the given data to clipboard and
+// returns true if success or false if failed.
+func write(t Format, buf []byte) (<-chan struct{}, error) {
+ var err error
+ switch t {
+ case FmtText:
+ err = writeText(buf)
+ case FmtImage:
+ err = writeImage(buf)
+ default:
+ return nil, errUnsupported
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Update change count
+ changeCountMu.Lock()
+ lastChangeCount++
+ currentCount := lastChangeCount
+ changeCountMu.Unlock()
+
+ // use unbuffered channel to prevent goroutine leak
+ changed := make(chan struct{}, 1)
+ go func() {
+ for {
+ time.Sleep(time.Second)
+ changeCountMu.Lock()
+ if lastChangeCount != currentCount {
+ changeCountMu.Unlock()
+ changed <- struct{}{}
+ close(changed)
+ return
+ }
+ changeCountMu.Unlock()
+ }
+ }()
+ return changed, nil
+}
+
+func writeText(buf []byte) error {
+ if len(buf) == 0 {
+ // Clear clipboard
+ script := `set the clipboard to ""`
+ cmd := exec.Command("osascript", "-e", script)
+ if err := cmd.Run(); err != nil {
+ return errUnavailable
+ }
+ return nil
+ }
+
+ // Escape the text for AppleScript
+ text := string(buf)
+ text = strings.ReplaceAll(text, "\\", "\\\\")
+ text = strings.ReplaceAll(text, "\"", "\\\"")
+
+ script := fmt.Sprintf(`set the clipboard to "%s"`, text)
+ cmd := exec.Command("osascript", "-e", script)
+ if err := cmd.Run(); err != nil {
+ return errUnavailable
+ }
+ return nil
+}
+func writeImage(buf []byte) error {
+ if len(buf) == 0 {
+ // Clear clipboard
+ script := `set the clipboard to ""`
+ cmd := exec.Command("osascript", "-e", script)
+ if err := cmd.Run(); err != nil {
+ return errUnavailable
+ }
+ return nil
+ }
+
+ // Create a temporary file to store the PNG data
+ tmpFile, err := os.CreateTemp("", "clipboard*.png")
+ if err != nil {
+ return errUnavailable
+ }
+ defer os.Remove(tmpFile.Name())
+
+ if _, err := tmpFile.Write(buf); err != nil {
+ tmpFile.Close()
+ return errUnavailable
+ }
+ tmpFile.Close()
+
+ // Use osascript to set clipboard to the image file
+ script := fmt.Sprintf(`
+ set theFile to POSIX file "%s"
+ set theImage to read theFile as «class PNGf»
+ set the clipboard to theImage
+ `, tmpFile.Name())
+
+ cmd := exec.Command("osascript", "-e", script)
+ if err := cmd.Run(); err != nil {
+ return errUnavailable
+ }
+ return nil
+}
+func watch(ctx context.Context, t Format) <-chan []byte {
+ recv := make(chan []byte, 1)
+ ti := time.NewTicker(time.Second)
+
+ // Get initial clipboard content
+ var lastContent []byte
+ if b := Read(t); b != nil {
+ lastContent = make([]byte, len(b))
+ copy(lastContent, b)
+ }
+
+ go func() {
+ defer close(recv)
+ defer ti.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ti.C:
+ b := Read(t)
+ if b == nil {
+ continue
+ }
+
+ // Check if content changed
+ if !bytes.Equal(lastContent, b) {
+ recv <- b
+ lastContent = make([]byte, len(b))
+ copy(lastContent, b)
+ }
+ }
+ }
+ }()
+ return recv
+}
diff --git a/packages/tui/internal/clipboard/clipboard_linux.go b/packages/tui/internal/clipboard/clipboard_linux.go
new file mode 100644
index 000000000..ca8a3bc6a
--- /dev/null
+++ b/packages/tui/internal/clipboard/clipboard_linux.go
@@ -0,0 +1,276 @@
+// Copyright 2021 The golang.design Initiative Authors.
+// All rights reserved. Use of this source code is governed
+// by a MIT license that can be found in the LICENSE file.
+//
+// Written by Changkun Ou <changkun.de>
+
+//go:build linux
+
+package clipboard
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ // Clipboard tools in order of preference
+ clipboardTools = []struct {
+ name string
+ readCmd []string
+ writeCmd []string
+ readImg []string
+ writeImg []string
+ available bool
+ }{
+ {
+ name: "xclip",
+ readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
+ writeCmd: []string{"xclip", "-selection", "clipboard"},
+ readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
+ writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
+ },
+ {
+ name: "xsel",
+ readCmd: []string{"xsel", "--clipboard", "--output"},
+ writeCmd: []string{"xsel", "--clipboard", "--input"},
+ readImg: []string{"xsel", "--clipboard", "--output"},
+ writeImg: []string{"xsel", "--clipboard", "--input"},
+ },
+ {
+ name: "wl-clipboard",
+ readCmd: []string{"wl-paste", "-n"},
+ writeCmd: []string{"wl-copy"},
+ readImg: []string{"wl-paste", "-t", "image/png", "-n"},
+ writeImg: []string{"wl-copy", "-t", "image/png"},
+ },
+ }
+
+ selectedTool int = -1
+ toolMutex sync.Mutex
+ lastChangeTime time.Time
+ changeTimeMu sync.Mutex
+)
+
+func initialize() error {
+ toolMutex.Lock()
+ defer toolMutex.Unlock()
+
+ if selectedTool >= 0 {
+ return nil // Already initialized
+ }
+
+ // Check which clipboard tool is available
+ for i, tool := range clipboardTools {
+ cmd := exec.Command("which", tool.name)
+ if err := cmd.Run(); err == nil {
+ clipboardTools[i].available = true
+ if selectedTool < 0 {
+ selectedTool = i
+ }
+ }
+ }
+
+ if selectedTool < 0 {
+ return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
+
+For X11 systems:
+ apt install -y xclip
+ # or
+ apt install -y xsel
+
+For Wayland systems:
+ apt install -y wl-clipboard
+
+If running in a headless environment, you may also need:
+ apt install -y xvfb
+ # and run:
+ Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
+ export DISPLAY=:99.0`, errUnavailable)
+ }
+
+ return nil
+}
+
+func read(t Format) (buf []byte, err error) {
+ toolMutex.Lock()
+ tool := clipboardTools[selectedTool]
+ toolMutex.Unlock()
+
+ switch t {
+ case FmtText:
+ return readText(tool)
+ case FmtImage:
+ return readImage(tool)
+ default:
+ return nil, errUnsupported
+ }
+}
+
+func readText(tool struct {
+ name string
+ readCmd []string
+ writeCmd []string
+ readImg []string
+ writeImg []string
+ available bool
+}) ([]byte, error) {
+ // First check if clipboard contains text
+ cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
+ out, err := cmd.Output()
+ if err != nil {
+ // Check if it's because clipboard contains non-text data
+ if tool.name == "xclip" {
+ // xclip returns error when clipboard doesn't contain requested type
+ checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
+ targets, _ := checkCmd.Output()
+ if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
+ return nil, errUnavailable
+ }
+ }
+ return nil, errUnavailable
+ }
+
+ return out, nil
+}
+
+func readImage(tool struct {
+ name string
+ readCmd []string
+ writeCmd []string
+ readImg []string
+ writeImg []string
+ available bool
+}) ([]byte, error) {
+ if tool.name == "xsel" {
+ // xsel doesn't support image types well, return error
+ return nil, errUnavailable
+ }
+
+ cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, errUnavailable
+ }
+
+ // Verify it's PNG data
+ if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
+ return nil, errUnavailable
+ }
+
+ return out, nil
+}
+
+func write(t Format, buf []byte) (<-chan struct{}, error) {
+ toolMutex.Lock()
+ tool := clipboardTools[selectedTool]
+ toolMutex.Unlock()
+
+ var cmd *exec.Cmd
+ switch t {
+ case FmtText:
+ if len(buf) == 0 {
+ // Write empty string
+ cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
+ cmd.Stdin = bytes.NewReader([]byte{})
+ } else {
+ cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
+ cmd.Stdin = bytes.NewReader(buf)
+ }
+ case FmtImage:
+ if tool.name == "xsel" {
+ // xsel doesn't support image types well
+ return nil, errUnavailable
+ }
+ if len(buf) == 0 {
+ // Clear clipboard
+ cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
+ cmd.Stdin = bytes.NewReader([]byte{})
+ } else {
+ cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
+ cmd.Stdin = bytes.NewReader(buf)
+ }
+ default:
+ return nil, errUnsupported
+ }
+
+ if err := cmd.Run(); err != nil {
+ return nil, errUnavailable
+ }
+
+ // Update change time
+ changeTimeMu.Lock()
+ lastChangeTime = time.Now()
+ currentTime := lastChangeTime
+ changeTimeMu.Unlock()
+
+ // Create change notification channel
+ changed := make(chan struct{}, 1)
+ go func() {
+ for {
+ time.Sleep(time.Second)
+ changeTimeMu.Lock()
+ if !lastChangeTime.Equal(currentTime) {
+ changeTimeMu.Unlock()
+ changed <- struct{}{}
+ close(changed)
+ return
+ }
+ changeTimeMu.Unlock()
+ }
+ }()
+
+ return changed, nil
+}
+
+func watch(ctx context.Context, t Format) <-chan []byte {
+ recv := make(chan []byte, 1)
+ ti := time.NewTicker(time.Second)
+
+ // Get initial clipboard content
+ var lastContent []byte
+ if b := Read(t); b != nil {
+ lastContent = make([]byte, len(b))
+ copy(lastContent, b)
+ }
+
+ go func() {
+ defer close(recv)
+ defer ti.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ti.C:
+ b := Read(t)
+ if b == nil {
+ continue
+ }
+
+ // Check if content changed
+ if !bytes.Equal(lastContent, b) {
+ recv <- b
+ lastContent = make([]byte, len(b))
+ copy(lastContent, b)
+ }
+ }
+ }
+ }()
+ return recv
+}
+
+// Helper function to check clipboard content type for xclip
+func getClipboardTargets() []string {
+ cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
+ out, err := cmd.Output()
+ if err != nil {
+ return nil
+ }
+ return strings.Split(string(out), "\n")
+}
diff --git a/packages/tui/internal/clipboard/clipboard_nocgo.go b/packages/tui/internal/clipboard/clipboard_nocgo.go
new file mode 100644
index 000000000..7b3e05f6c
--- /dev/null
+++ b/packages/tui/internal/clipboard/clipboard_nocgo.go
@@ -0,0 +1,25 @@
+//go:build !windows && !darwin && !linux && !cgo
+
+package clipboard
+
+import "context"
+
+func initialize() error {
+ return errNoCgo
+}
+
+func read(t Format) (buf []byte, err error) {
+ panic("clipboard: cannot use when CGO_ENABLED=0")
+}
+
+func readc(t string) ([]byte, error) {
+ panic("clipboard: cannot use when CGO_ENABLED=0")
+}
+
+func write(t Format, buf []byte) (<-chan struct{}, error) {
+ panic("clipboard: cannot use when CGO_ENABLED=0")
+}
+
+func watch(ctx context.Context, t Format) <-chan []byte {
+ panic("clipboard: cannot use when CGO_ENABLED=0")
+}
diff --git a/packages/tui/internal/clipboard/clipboard_windows.go b/packages/tui/internal/clipboard/clipboard_windows.go
new file mode 100644
index 000000000..bd042cda8
--- /dev/null
+++ b/packages/tui/internal/clipboard/clipboard_windows.go
@@ -0,0 +1,551 @@
+// Copyright 2021 The golang.design Initiative Authors.
+// All rights reserved. Use of this source code is governed
+// by a MIT license that can be found in the LICENSE file.
+//
+// Written by Changkun Ou <changkun.de>
+
+//go:build windows
+
+package clipboard
+
+// Interacting with Clipboard on Windows:
+// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "reflect"
+ "runtime"
+ "syscall"
+ "time"
+ "unicode/utf16"
+ "unsafe"
+
+ "golang.org/x/image/bmp"
+)
+
+func initialize() error { return nil }
+
+// readText reads the clipboard and returns the text data if presents.
+// The caller is responsible for opening/closing the clipboard before
+// calling this function.
+func readText() (buf []byte, err error) {
+ hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
+ if hMem == 0 {
+ return nil, err
+ }
+ p, _, err := gLock.Call(hMem)
+ if p == 0 {
+ return nil, err
+ }
+ defer gUnlock.Call(hMem)
+
+ // Find NUL terminator
+ n := 0
+ for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
+ ptr = unsafe.Pointer(uintptr(ptr) +
+ unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
+ }
+
+ var s []uint16
+ h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
+ h.Data = p
+ h.Len = n
+ h.Cap = n
+ return []byte(string(utf16.Decode(s))), nil
+}
+
+// writeText writes given data to the clipboard. It is the caller's
+// responsibility for opening/closing the clipboard before calling
+// this function.
+func writeText(buf []byte) error {
+ r, _, err := emptyClipboard.Call()
+ if r == 0 {
+ return fmt.Errorf("failed to clear clipboard: %w", err)
+ }
+
+ // empty text, we are done here.
+ if len(buf) == 0 {
+ return nil
+ }
+
+ s, err := syscall.UTF16FromString(string(buf))
+ if err != nil {
+ return fmt.Errorf("failed to convert given string: %w", err)
+ }
+
+ hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
+ if hMem == 0 {
+ return fmt.Errorf("failed to alloc global memory: %w", err)
+ }
+
+ p, _, err := gLock.Call(hMem)
+ if p == 0 {
+ return fmt.Errorf("failed to lock global memory: %w", err)
+ }
+ defer gUnlock.Call(hMem)
+
+ // no return value
+ memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
+ uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
+
+ v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
+ if v == 0 {
+ gFree.Call(hMem)
+ return fmt.Errorf("failed to set text to clipboard: %w", err)
+ }
+
+ return nil
+}
+
+// readImage reads the clipboard and returns PNG encoded image data
+// if presents. The caller is responsible for opening/closing the
+// clipboard before calling this function.
+func readImage() ([]byte, error) {
+ hMem, _, err := getClipboardData.Call(cFmtDIBV5)
+ if hMem == 0 {
+ // second chance to try FmtDIB
+ return readImageDib()
+ }
+ p, _, err := gLock.Call(hMem)
+ if p == 0 {
+ return nil, err
+ }
+ defer gUnlock.Call(hMem)
+
+ // inspect header information
+ info := (*bitmapV5Header)(unsafe.Pointer(p))
+
+ // maybe deal with other formats?
+ if info.BitCount != 32 {
+ return nil, errUnsupported
+ }
+
+ var data []byte
+ sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
+ sh.Data = uintptr(p)
+ sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
+ sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
+ img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
+ offset := int(info.Size)
+ stride := int(info.Width)
+ for y := 0; y < int(info.Height); y++ {
+ for x := 0; x < int(info.Width); x++ {
+ idx := offset + 4*(y*stride+x)
+ xhat := (x + int(info.Width)) % int(info.Width)
+ yhat := int(info.Height) - 1 - y
+ r := data[idx+2]
+ g := data[idx+1]
+ b := data[idx+0]
+ a := data[idx+3]
+ img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
+ }
+ }
+ // always use PNG encoding.
+ var buf bytes.Buffer
+ png.Encode(&buf, img)
+ return buf.Bytes(), nil
+}
+
+func readImageDib() ([]byte, error) {
+ const (
+ fileHeaderLen = 14
+ infoHeaderLen = 40
+ cFmtDIB = 8
+ )
+
+ hClipDat, _, err := getClipboardData.Call(cFmtDIB)
+ if err != nil {
+ return nil, errors.New("not dib format data: " + err.Error())
+ }
+ pMemBlk, _, err := gLock.Call(hClipDat)
+ if pMemBlk == 0 {
+ return nil, errors.New("failed to call global lock: " + err.Error())
+ }
+ defer gUnlock.Call(hClipDat)
+
+ bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
+ dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
+
+ if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
+ iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
+ dataSize += iSizeImage
+ }
+ buf := new(bytes.Buffer)
+ binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
+ binary.Write(buf, binary.LittleEndian, uint32(dataSize))
+ binary.Write(buf, binary.LittleEndian, uint32(0))
+ const sizeof_colorbar = 0
+ binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
+ j := 0
+ for i := fileHeaderLen; i < int(dataSize); i++ {
+ binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
+ j++
+ }
+ return bmpToPng(buf)
+}
+
+func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
+ var f bytes.Buffer
+ original_image, err := bmp.Decode(bmpBuf)
+ if err != nil {
+ return nil, err
+ }
+ err = png.Encode(&f, original_image)
+ if err != nil {
+ return nil, err
+ }
+ return f.Bytes(), nil
+}
+
+func writeImage(buf []byte) error {
+ r, _, err := emptyClipboard.Call()
+ if r == 0 {
+ return fmt.Errorf("failed to clear clipboard: %w", err)
+ }
+
+ // empty text, we are done here.
+ if len(buf) == 0 {
+ return nil
+ }
+
+ img, err := png.Decode(bytes.NewReader(buf))
+ if err != nil {
+ return fmt.Errorf("input bytes is not PNG encoded: %w", err)
+ }
+
+ offset := unsafe.Sizeof(bitmapV5Header{})
+ width := img.Bounds().Dx()
+ height := img.Bounds().Dy()
+ imageSize := 4 * width * height
+
+ data := make([]byte, int(offset)+imageSize)
+ for y := 0; y < height; y++ {
+ for x := 0; x < width; x++ {
+ idx := int(offset) + 4*(y*width+x)
+ r, g, b, a := img.At(x, height-1-y).RGBA()
+ data[idx+2] = uint8(r)
+ data[idx+1] = uint8(g)
+ data[idx+0] = uint8(b)
+ data[idx+3] = uint8(a)
+ }
+ }
+
+ info := bitmapV5Header{}
+ info.Size = uint32(offset)
+ info.Width = int32(width)
+ info.Height = int32(height)
+ info.Planes = 1
+ info.Compression = 0 // BI_RGB
+ info.SizeImage = uint32(4 * info.Width * info.Height)
+ info.RedMask = 0xff0000 // default mask
+ info.GreenMask = 0xff00
+ info.BlueMask = 0xff
+ info.AlphaMask = 0xff000000
+ info.BitCount = 32 // we only deal with 32 bpp at the moment.
+ // Use calibrated RGB values as Go's image/png assumes linear color space.
+ // Other options:
+ // - LCS_CALIBRATED_RGB = 0x00000000
+ // - LCS_sRGB = 0x73524742
+ // - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
+ // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
+ info.CSType = 0x73524742
+ // Use GL_IMAGES for GamutMappingIntent
+ // Other options:
+ // - LCS_GM_ABS_COLORIMETRIC = 0x00000008
+ // - LCS_GM_BUSINESS = 0x00000001
+ // - LCS_GM_GRAPHICS = 0x00000002
+ // - LCS_GM_IMAGES = 0x00000004
+ // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
+ info.Intent = 4 // LCS_GM_IMAGES
+
+ infob := make([]byte, int(unsafe.Sizeof(info)))
+ for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
+ infob[i] = v
+ }
+ copy(data[:], infob[:])
+
+ hMem, _, err := gAlloc.Call(gmemMoveable,
+ uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
+ if hMem == 0 {
+ return fmt.Errorf("failed to alloc global memory: %w", err)
+ }
+
+ p, _, err := gLock.Call(hMem)
+ if p == 0 {
+ return fmt.Errorf("failed to lock global memory: %w", err)
+ }
+ defer gUnlock.Call(hMem)
+
+ memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
+ uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
+
+ v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
+ if v == 0 {
+ gFree.Call(hMem)
+ return fmt.Errorf("failed to set text to clipboard: %w", err)
+ }
+
+ return nil
+}
+
+func read(t Format) (buf []byte, err error) {
+ // On Windows, OpenClipboard and CloseClipboard must be executed on
+ // the same thread. Thus, lock the OS thread for further execution.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ var format uintptr
+ switch t {
+ case FmtImage:
+ format = cFmtDIBV5
+ case FmtText:
+ fallthrough
+ default:
+ format = cFmtUnicodeText
+ }
+
+ // check if clipboard is avaliable for the requested format
+ r, _, err := isClipboardFormatAvailable.Call(format)
+ if r == 0 {
+ return nil, errUnavailable
+ }
+
+ // try again until open clipboard successed
+ for {
+ r, _, _ = openClipboard.Call()
+ if r == 0 {
+ continue
+ }
+ break
+ }
+ defer closeClipboard.Call()
+
+ switch format {
+ case cFmtDIBV5:
+ return readImage()
+ case cFmtUnicodeText:
+ fallthrough
+ default:
+ return readText()
+ }
+}
+
+// write writes the given data to clipboard and
+// returns true if success or false if failed.
+func write(t Format, buf []byte) (<-chan struct{}, error) {
+ errch := make(chan error)
+ changed := make(chan struct{}, 1)
+ go func() {
+ // make sure GetClipboardSequenceNumber happens with
+ // OpenClipboard on the same thread.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ for {
+ r, _, _ := openClipboard.Call(0)
+ if r == 0 {
+ continue
+ }
+ break
+ }
+
+ // var param uintptr
+ switch t {
+ case FmtImage:
+ err := writeImage(buf)
+ if err != nil {
+ errch <- err
+ closeClipboard.Call()
+ return
+ }
+ case FmtText:
+ fallthrough
+ default:
+ // param = cFmtUnicodeText
+ err := writeText(buf)
+ if err != nil {
+ errch <- err
+ closeClipboard.Call()
+ return
+ }
+ }
+ // Close the clipboard otherwise other applications cannot
+ // paste the data.
+ closeClipboard.Call()
+
+ cnt, _, _ := getClipboardSequenceNumber.Call()
+ errch <- nil
+ for {
+ time.Sleep(time.Second)
+ cur, _, _ := getClipboardSequenceNumber.Call()
+ if cur != cnt {
+ changed <- struct{}{}
+ close(changed)
+ return
+ }
+ }
+ }()
+ err := <-errch
+ if err != nil {
+ return nil, err
+ }
+ return changed, nil
+}
+
+func watch(ctx context.Context, t Format) <-chan []byte {
+ recv := make(chan []byte, 1)
+ ready := make(chan struct{})
+ go func() {
+ // not sure if we are too slow or the user too fast :)
+ ti := time.NewTicker(time.Second)
+ cnt, _, _ := getClipboardSequenceNumber.Call()
+ ready <- struct{}{}
+ for {
+ select {
+ case <-ctx.Done():
+ close(recv)
+ return
+ case <-ti.C:
+ cur, _, _ := getClipboardSequenceNumber.Call()
+ if cnt != cur {
+ b := Read(t)
+ if b == nil {
+ continue
+ }
+ recv <- b
+ cnt = cur
+ }
+ }
+ }
+ }()
+ <-ready
+ return recv
+}
+
+const (
+ cFmtBitmap = 2 // Win+PrintScreen
+ cFmtUnicodeText = 13
+ cFmtDIBV5 = 17
+ // Screenshot taken from special shortcut is in different format (why??), see:
+ // https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
+ cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
+ gmemMoveable = 0x0002
+)
+
+// BITMAPV5Header structure, see:
+// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
+type bitmapV5Header struct {
+ Size uint32
+ Width int32
+ Height int32
+ Planes uint16
+ BitCount uint16
+ Compression uint32
+ SizeImage uint32
+ XPelsPerMeter int32
+ YPelsPerMeter int32
+ ClrUsed uint32
+ ClrImportant uint32
+ RedMask uint32
+ GreenMask uint32
+ BlueMask uint32
+ AlphaMask uint32
+ CSType uint32
+ Endpoints struct {
+ CiexyzRed, CiexyzGreen, CiexyzBlue struct {
+ CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
+ }
+ }
+ GammaRed uint32
+ GammaGreen uint32
+ GammaBlue uint32
+ Intent uint32
+ ProfileData uint32
+ ProfileSize uint32
+ Reserved uint32
+}
+
+type bitmapHeader struct {
+ Size uint32
+ Width uint32
+ Height uint32
+ PLanes uint16
+ BitCount uint16
+ Compression uint32
+ SizeImage uint32
+ XPelsPerMeter uint32
+ YPelsPerMeter uint32
+ ClrUsed uint32
+ ClrImportant uint32
+}
+
+// Calling a Windows DLL, see:
+// https://github.com/golang/go/wiki/WindowsDLLs
+var (
+ user32 = syscall.MustLoadDLL("user32")
+ // Opens the clipboard for examination and prevents other
+ // applications from modifying the clipboard content.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
+ openClipboard = user32.MustFindProc("OpenClipboard")
+ // Closes the clipboard.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
+ closeClipboard = user32.MustFindProc("CloseClipboard")
+ // Empties the clipboard and frees handles to data in the clipboard.
+ // The function then assigns ownership of the clipboard to the
+ // window that currently has the clipboard open.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
+ emptyClipboard = user32.MustFindProc("EmptyClipboard")
+ // Retrieves data from the clipboard in a specified format.
+ // The clipboard must have been opened previously.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
+ getClipboardData = user32.MustFindProc("GetClipboardData")
+ // Places data on the clipboard in a specified clipboard format.
+ // The window must be the current clipboard owner, and the
+ // application must have called the OpenClipboard function. (When
+ // responding to the WM_RENDERFORMAT message, the clipboard owner
+ // must not call OpenClipboard before calling SetClipboardData.)
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
+ setClipboardData = user32.MustFindProc("SetClipboardData")
+ // Determines whether the clipboard contains data in the specified format.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
+ isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
+ // Clipboard data formats are stored in an ordered list. To perform
+ // an enumeration of clipboard data formats, you make a series of
+ // calls to the EnumClipboardFormats function. For each call, the
+ // format parameter specifies an available clipboard format, and the
+ // function returns the next available clipboard format.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
+ enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
+ // Retrieves the clipboard sequence number for the current window station.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
+ getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
+ // Registers a new clipboard format. This format can then be used as
+ // a valid clipboard format.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
+ registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
+
+ kernel32 = syscall.NewLazyDLL("kernel32")
+
+ // Locks a global memory object and returns a pointer to the first
+ // byte of the object's memory block.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
+ gLock = kernel32.NewProc("GlobalLock")
+ // Decrements the lock count associated with a memory object that was
+ // allocated with GMEM_MOVEABLE. This function has no effect on memory
+ // objects allocated with GMEM_FIXED.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
+ gUnlock = kernel32.NewProc("GlobalUnlock")
+ // Allocates the specified number of bytes from the heap.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
+ gAlloc = kernel32.NewProc("GlobalAlloc")
+ // Frees the specified global memory object and invalidates its handle.
+ // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
+ gFree = kernel32.NewProc("GlobalFree")
+ memMove = kernel32.NewProc("RtlMoveMemory")
+)
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 071f22d0e..6053c9c01 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -15,13 +15,13 @@ import (
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/clipboard"
"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/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
- "golang.design/x/clipboard"
)
type EditorComponent interface {