on main help sign in
feat: add clipboard image paste functionality to chat editor (#181) (#1151)

Co-authored-by: Ayman Bagabas <ayman.bagabas@gmail.com>

commit67437151e91296db7c25908ef0e69031339de56a authorkslamph <15257433+kslamph@users.noreply.github.com> dateWed 14 Jan 2026, 06:46 (9 months ago) parent340defd
4 files changed+97−6
go.mod+4−2 go.sum+8−4 internal/tui/components/chat/editor/editor.go+79 internal/tui/components/chat/editor/keys.go+6
go.mod+4−2
@@ -15,6 +15,7 @@ require (
1515 github.com/PuerkitoBio/goquery v1.11.0
1616 github.com/alecthomas/chroma/v2 v2.22.0
1717 github.com/atotto/clipboard v0.1.4
18+ github.com/aymanbagabas/go-nativeclipboard v0.1.2
1819 github.com/aymanbagabas/go-udiff v0.3.1
1920 github.com/bmatcuk/doublestar/v4 v4.9.2
2021 github.com/charlievieth/fastwalk v1.0.14
@@ -100,13 +101,14 @@ require (
100101 github.com/charmbracelet/x/json v0.2.0 // indirect
101102 github.com/charmbracelet/x/termios v0.1.1 // indirect
102103 github.com/charmbracelet/x/windows v0.2.2 // indirect
103− github.com/clipperhouse/displaywidth v0.6.1 // indirect
104+ github.com/clipperhouse/displaywidth v0.6.2 // indirect
104105 github.com/clipperhouse/stringish v0.1.1 // indirect
105106 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
106107 github.com/davecgh/go-spew v1.1.1 // indirect
107108 github.com/disintegration/gift v1.1.2 // indirect
108109 github.com/dlclark/regexp2 v1.11.5 // indirect
109110 github.com/dustin/go-humanize v1.0.1 // indirect
111+ github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
110112 github.com/felixge/httpsnoop v1.0.4 // indirect
111113 github.com/fsnotify/fsnotify v1.9.0 // indirect
112114 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -171,7 +173,7 @@ require (
171173 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
172174 golang.org/x/crypto v0.47.0 // indirect
173175 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
174− golang.org/x/image v0.27.0 // indirect
176+ golang.org/x/image v0.34.0 // indirect
175177 golang.org/x/oauth2 v0.34.0 // indirect
176178 golang.org/x/sys v0.40.0 // indirect
177179 golang.org/x/term v0.39.0 // indirect
go.sum+8−4
@@ -78,6 +78,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
7878github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
7979github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
8080github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
81+github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
82+github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
8183github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8284github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
8385github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -126,8 +128,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
126128github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
127129github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
128130github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
129−github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
130−github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
131+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
132+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
131133github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
132134github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
133135github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -150,6 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
150152github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
151153github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
152154github.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=
153157github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
154158github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
155159github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -389,8 +393,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
389393golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
390394golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
391395golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
392−golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
393−golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
396+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
397+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
394398golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
395399golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
396400golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
internal/tui/components/chat/editor/editor.go+79
@@ -16,6 +16,7 @@ import (
1616 "charm.land/bubbles/v2/textarea"
1717 tea "charm.land/bubbletea/v2"
1818 "charm.land/lipgloss/v2"
19+ nativeclipboard "github.com/aymanbagabas/go-nativeclipboard"
1920 "github.com/charmbracelet/crush/internal/app"
2021 "github.com/charmbracelet/crush/internal/filetracker"
2122 "github.com/charmbracelet/crush/internal/fsext"
@@ -338,6 +339,84 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
338339 m.textarea.InsertRune('\n')
339340 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
340341 }
342+ // Handle image paste from clipboard
343+ if key.Matches(msg, m.keyMap.PasteImage) {
344+ imageData, err := nativeclipboard.Image.Read()
345+
346+ if err != nil || len(imageData) == 0 {
347+ // If no image data found, try to get text data (could be file path)
348+ var textData []byte
349+ textData, err = nativeclipboard.Text.Read()
350+ if err != nil || len(textData) == 0 {
351+ // If clipboard is empty, show a warning
352+ return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
353+ }
354+
355+ // Check if the text data is a file path
356+ textStr := string(textData)
357+ // First, try to interpret as a file path (existing functionality)
358+ path := strings.ReplaceAll(textStr, "\\ ", " ")
359+ path, err = filepath.Abs(strings.TrimSpace(path))
360+ if err == nil {
361+ isAllowedType := false
362+ for _, ext := range filepicker.AllowedTypes {
363+ if strings.HasSuffix(path, ext) {
364+ isAllowedType = true
365+ break
366+ }
367+ }
368+ if isAllowedType {
369+ tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
370+ if !tooBig {
371+ content, err := os.ReadFile(path)
372+ if err == nil {
373+ mimeBufferSize := min(512, len(content))
374+ mimeType := http.DetectContentType(content[:mimeBufferSize])
375+ fileName := filepath.Base(path)
376+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
377+ return m, util.CmdHandler(filepicker.FilePickedMsg{
378+ Attachment: attachment,
379+ })
380+ }
381+ }
382+ }
383+ }
384+
385+ // If not a valid file path, show a warning
386+ return m, util.ReportWarn("No image found in clipboard")
387+ } else {
388+ // We have image data from the clipboard
389+ // Create a temporary file to store the clipboard image data
390+ tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
391+ if err != nil {
392+ return m, util.ReportError(err)
393+ }
394+ defer tempFile.Close()
395+
396+ // Write clipboard content to the temporary file
397+ _, err = tempFile.Write(imageData)
398+ if err != nil {
399+ return m, util.ReportError(err)
400+ }
401+
402+ // Determine the file extension based on the image data
403+ mimeBufferSize := min(512, len(imageData))
404+ mimeType := http.DetectContentType(imageData[:mimeBufferSize])
405+
406+ // Create an attachment from the temporary file
407+ fileName := filepath.Base(tempFile.Name())
408+ attachment := message.Attachment{
409+ FilePath: tempFile.Name(),
410+ FileName: fileName,
411+ MimeType: mimeType,
412+ Content: imageData,
413+ }
414+
415+ return m, util.CmdHandler(filepicker.FilePickedMsg{
416+ Attachment: attachment,
417+ })
418+ }
419+ }
341420 // Handle Enter key
342421 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
343422 value := m.textarea.Value()
internal/tui/components/chat/editor/keys.go+6
@@ -9,6 +9,7 @@ type EditorKeyMap struct {
99 SendMessage key.Binding
1010 OpenEditor key.Binding
1111 Newline key.Binding
12+ PasteImage key.Binding
1213}
1314
1415func DefaultEditorKeyMap() EditorKeyMap {
@@ -32,6 +33,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
3233 // to reflect that.
3334 key.WithHelp("ctrl+j", "newline"),
3435 ),
36+ PasteImage: key.NewBinding(
37+ key.WithKeys("ctrl+v"),
38+ key.WithHelp("ctrl+v", "paste image from clipboard"),
39+ ),
3540 }
3641}
3742
@@ -42,6 +47,7 @@ func (k EditorKeyMap) KeyBindings() []key.Binding {
4247 k.SendMessage,
4348 k.OpenEditor,
4449 k.Newline,
50+ k.PasteImage,
4551 AttachmentsKeyMaps.AttachmentDeleteMode,
4652 AttachmentsKeyMaps.DeleteAllAttachments,
4753 AttachmentsKeyMaps.Escape,