on main help sign in
feat: add clipboard image paste support (ctrl+v) (#2148)
fc9db05Carlos Alexandro Becker
@@ -16,6 +16,7 @@ require (
16 github.com/PuerkitoBio/goquery v1.11.0
16 github.com/PuerkitoBio/goquery v1.11.0
17 github.com/alecthomas/chroma/v2 v2.23.1
17 github.com/alecthomas/chroma/v2 v2.23.1
18 github.com/atotto/clipboard v0.1.4
18 github.com/atotto/clipboard v0.1.4
19+ github.com/aymanbagabas/go-nativeclipboard v0.1.2
19 github.com/aymanbagabas/go-udiff v0.3.1
20 github.com/aymanbagabas/go-udiff v0.3.1
20 github.com/bmatcuk/doublestar/v4 v4.10.0
21 github.com/bmatcuk/doublestar/v4 v4.10.0
21 github.com/charlievieth/fastwalk v1.0.14
22 github.com/charlievieth/fastwalk v1.0.14
@@ -104,6 +105,7 @@ require (
104 github.com/clipperhouse/stringish v0.1.1 // indirect
105 github.com/clipperhouse/stringish v0.1.1 // indirect
105 github.com/davecgh/go-spew v1.1.1 // indirect
106 github.com/davecgh/go-spew v1.1.1 // indirect
106 github.com/dlclark/regexp2 v1.11.5 // indirect
107 github.com/dlclark/regexp2 v1.11.5 // indirect
108+ github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
107 github.com/felixge/httpsnoop v1.0.4 // indirect
109 github.com/felixge/httpsnoop v1.0.4 // indirect
108 github.com/fsnotify/fsnotify v1.9.0 // indirect
110 github.com/fsnotify/fsnotify v1.9.0 // indirect
109 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
111 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
go.sum+4
@@ -80,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
80github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
80github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
81github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
81github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
82github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
82github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
83+github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
84+github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
83github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
85github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
84github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
86github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
85github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
87github.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
150github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
152github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
151github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
153github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
152github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
154github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
155+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
156+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
153github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
157github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
154github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
158github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
155github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
159github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
internal/ui/model/keys.go+5
@@ -9,6 +9,7 @@ type KeyMap struct {
9 OpenEditor key.Binding
9 OpenEditor key.Binding
10 Newline key.Binding
10 Newline key.Binding
11 AddImage key.Binding
11 AddImage key.Binding
12+ PasteImage key.Binding
12 MentionFile key.Binding
13 MentionFile key.Binding
13 Commands key.Binding
14 Commands key.Binding
14
15
@@ -120,6 +121,10 @@ func DefaultKeyMap() KeyMap {
120 key.WithKeys("ctrl+f"),
121 key.WithKeys("ctrl+f"),
121 key.WithHelp("ctrl+f", "add image"),
122 key.WithHelp("ctrl+f", "add image"),
122 )
123 )
124+ km.Editor.PasteImage = key.NewBinding(
125+ key.WithKeys("ctrl+v"),
126+ key.WithHelp("ctrl+v", "paste image from clipboard"),
127+ )
123 km.Editor.MentionFile = key.NewBinding(
128 km.Editor.MentionFile = key.NewBinding(
124 key.WithKeys("@"),
129 key.WithKeys("@"),
125 key.WithHelp("@", "mention file"),
130 key.WithHelp("@", "mention file"),
internal/ui/model/ui.go+80
@@ -24,6 +24,7 @@ import (
24 tea "charm.land/bubbletea/v2"
24 tea "charm.land/bubbletea/v2"
25 "charm.land/catwalk/pkg/catwalk"
25 "charm.land/catwalk/pkg/catwalk"
26 "charm.land/lipgloss/v2"
26 "charm.land/lipgloss/v2"
27+ nativeclipboard "github.com/aymanbagabas/go-nativeclipboard"
27 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
28 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
28 "github.com/charmbracelet/crush/internal/app"
29 "github.com/charmbracelet/crush/internal/app"
29 "github.com/charmbracelet/crush/internal/commands"
30 "github.com/charmbracelet/crush/internal/commands"
@@ -1549,6 +1550,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1549 cmds = append(cmds, cmd)
1550 cmds = append(cmds, cmd)
1550 }
1551 }
1551
1552
1553+ case key.Matches(msg, m.keyMap.Editor.PasteImage):
1554+ cmds = append(cmds, m.pasteImageFromClipboard)
1555+
1552 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1556 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1553 value := m.textarea.Value()
1557 value := m.textarea.Value()
1554 if before, ok := strings.CutSuffix(value, "\\"); ok {
1558 if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -2081,6 +2085,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2081 []key.Binding{
2085 []key.Binding{
2082 k.Editor.Newline,
2086 k.Editor.Newline,
2083 k.Editor.AddImage,
2087 k.Editor.AddImage,
2088+ k.Editor.PasteImage,
2084 k.Editor.MentionFile,
2089 k.Editor.MentionFile,
2085 k.Editor.OpenEditor,
2090 k.Editor.OpenEditor,
2086 },
2091 },
@@ -2129,6 +2134,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2129 []key.Binding{
2134 []key.Binding{
2130 k.Editor.Newline,
2135 k.Editor.Newline,
2131 k.Editor.AddImage,
2136 k.Editor.AddImage,
2137+ k.Editor.PasteImage,
2132 k.Editor.MentionFile,
2138 k.Editor.MentionFile,
2133 k.Editor.OpenEditor,
2139 k.Editor.OpenEditor,
2134 },
2140 },
@@ -3061,6 +3067,80 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3061 }
3067 }
3062}
3068}
3063
3069
3070+// pasteImageFromClipboard reads image data from the system clipboard and
3071+// creates an attachment. If no image data is found, it falls back to
3072+// interpreting clipboard text as a file path.
3073+func (m *UI) pasteImageFromClipboard() tea.Msg {
3074+ imageData, err := nativeclipboard.Image.Read()
3075+ if int64(len(imageData)) > common.MaxAttachmentSize {
3076+ return util.InfoMsg{
3077+ Type: util.InfoTypeError,
3078+ Msg: "File too large, max 5MB",
3079+ }
3080+ }
3081+ name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3082+ if err == nil {
3083+ return message.Attachment{
3084+ FilePath: name,
3085+ FileName: name,
3086+ MimeType: mimeOf(imageData),
3087+ Content: imageData,
3088+ }
3089+ }
3090+
3091+ textData, textErr := nativeclipboard.Text.Read()
3092+ if textErr != nil || len(textData) == 0 {
3093+ return util.NewInfoMsg("Clipboard is empty or does not contain an image")
3094+ }
3095+
3096+ path := strings.TrimSpace(string(textData))
3097+ path = strings.ReplaceAll(path, "\\ ", " ")
3098+ if _, statErr := os.Stat(path); statErr != nil {
3099+ return util.NewInfoMsg("Clipboard does not contain an image or valid file path")
3100+ }
3101+
3102+ lowerPath := strings.ToLower(path)
3103+ isAllowed := false
3104+ for _, ext := range common.AllowedImageTypes {
3105+ if strings.HasSuffix(lowerPath, ext) {
3106+ isAllowed = true
3107+ break
3108+ }
3109+ }
3110+ if !isAllowed {
3111+ return util.NewInfoMsg("File type is not a supported image format")
3112+ }
3113+
3114+ fileInfo, statErr := os.Stat(path)
3115+ if statErr != nil {
3116+ return util.InfoMsg{
3117+ Type: util.InfoTypeError,
3118+ Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3119+ }
3120+ }
3121+ if fileInfo.Size() > common.MaxAttachmentSize {
3122+ return util.InfoMsg{
3123+ Type: util.InfoTypeError,
3124+ Msg: "File too large, max 5MB",
3125+ }
3126+ }
3127+
3128+ content, readErr := os.ReadFile(path)
3129+ if readErr != nil {
3130+ return util.InfoMsg{
3131+ Type: util.InfoTypeError,
3132+ Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3133+ }
3134+ }
3135+
3136+ return message.Attachment{
3137+ FilePath: path,
3138+ FileName: filepath.Base(path),
3139+ MimeType: mimeOf(content),
3140+ Content: content,
3141+ }
3142+}
3143+
3064var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3144var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3065
3145
3066func (m *UI) pasteIdx() int {
3146func (m *UI) pasteIdx() int {