feat: add clipboard image paste support (ctrl+v) (#2148)

Carlos Alexandro Becker created

* feat: add clipboard image paste support (ctrl+v)

Port of #1151 to the new UI.

Assisted-by: Claude Opus 4.6 via Crush <crush@charm.land>

* fix: simplify

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: go mod tidy

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

go.mod                    |  2 +
go.sum                    |  4 ++
internal/ui/model/keys.go |  5 ++
internal/ui/model/ui.go   | 80 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 91 insertions(+)

Detailed changes

go.mod 🔗

@@ -16,6 +16,7 @@ require (
 	github.com/PuerkitoBio/goquery v1.11.0
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/atotto/clipboard v0.1.4
+	github.com/aymanbagabas/go-nativeclipboard v0.1.2
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.10.0
 	github.com/charlievieth/fastwalk v1.0.14
@@ -104,6 +105,7 @@ require (
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
+	github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect

go.sum 🔗

@@ -80,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
 github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
 github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
+github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -150,6 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=

internal/ui/model/keys.go 🔗

@@ -9,6 +9,7 @@ type KeyMap struct {
 		OpenEditor  key.Binding
 		Newline     key.Binding
 		AddImage    key.Binding
+		PasteImage  key.Binding
 		MentionFile key.Binding
 		Commands    key.Binding
 
@@ -120,6 +121,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("ctrl+f"),
 		key.WithHelp("ctrl+f", "add image"),
 	)
+	km.Editor.PasteImage = key.NewBinding(
+		key.WithKeys("ctrl+v"),
+		key.WithHelp("ctrl+v", "paste image from clipboard"),
+	)
 	km.Editor.MentionFile = key.NewBinding(
 		key.WithKeys("@"),
 		key.WithHelp("@", "mention file"),

internal/ui/model/ui.go 🔗

@@ -24,6 +24,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
+	nativeclipboard "github.com/aymanbagabas/go-nativeclipboard"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/commands"
@@ -1549,6 +1550,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					cmds = append(cmds, cmd)
 				}
 
+			case key.Matches(msg, m.keyMap.Editor.PasteImage):
+				cmds = append(cmds, m.pasteImageFromClipboard)
+
 			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 				value := m.textarea.Value()
 				if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -2081,6 +2085,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 				[]key.Binding{
 					k.Editor.Newline,
 					k.Editor.AddImage,
+					k.Editor.PasteImage,
 					k.Editor.MentionFile,
 					k.Editor.OpenEditor,
 				},
@@ -2129,6 +2134,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 				[]key.Binding{
 					k.Editor.Newline,
 					k.Editor.AddImage,
+					k.Editor.PasteImage,
 					k.Editor.MentionFile,
 					k.Editor.OpenEditor,
 				},
@@ -3061,6 +3067,80 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd {
 	}
 }
 
+// pasteImageFromClipboard reads image data from the system clipboard and
+// creates an attachment. If no image data is found, it falls back to
+// interpreting clipboard text as a file path.
+func (m *UI) pasteImageFromClipboard() tea.Msg {
+	imageData, err := nativeclipboard.Image.Read()
+	if int64(len(imageData)) > common.MaxAttachmentSize {
+		return util.InfoMsg{
+			Type: util.InfoTypeError,
+			Msg:  "File too large, max 5MB",
+		}
+	}
+	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
+	if err == nil {
+		return message.Attachment{
+			FilePath: name,
+			FileName: name,
+			MimeType: mimeOf(imageData),
+			Content:  imageData,
+		}
+	}
+
+	textData, textErr := nativeclipboard.Text.Read()
+	if textErr != nil || len(textData) == 0 {
+		return util.NewInfoMsg("Clipboard is empty or does not contain an image")
+	}
+
+	path := strings.TrimSpace(string(textData))
+	path = strings.ReplaceAll(path, "\\ ", " ")
+	if _, statErr := os.Stat(path); statErr != nil {
+		return util.NewInfoMsg("Clipboard does not contain an image or valid file path")
+	}
+
+	lowerPath := strings.ToLower(path)
+	isAllowed := false
+	for _, ext := range common.AllowedImageTypes {
+		if strings.HasSuffix(lowerPath, ext) {
+			isAllowed = true
+			break
+		}
+	}
+	if !isAllowed {
+		return util.NewInfoMsg("File type is not a supported image format")
+	}
+
+	fileInfo, statErr := os.Stat(path)
+	if statErr != nil {
+		return util.InfoMsg{
+			Type: util.InfoTypeError,
+			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
+		}
+	}
+	if fileInfo.Size() > common.MaxAttachmentSize {
+		return util.InfoMsg{
+			Type: util.InfoTypeError,
+			Msg:  "File too large, max 5MB",
+		}
+	}
+
+	content, readErr := os.ReadFile(path)
+	if readErr != nil {
+		return util.InfoMsg{
+			Type: util.InfoTypeError,
+			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
+		}
+	}
+
+	return message.Attachment{
+		FilePath: path,
+		FileName: filepath.Base(path),
+		MimeType: mimeOf(content),
+		Content:  content,
+	}
+}
+
 var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
 
 func (m *UI) pasteIdx() int {