diff --git a/go.mod b/go.mod index 1785e4bba17e39f215b9b628bff79343ec9026d4..daca23284eb1b3e6280210d13f6fb852371b9caf 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 8c0c564195b69579fe7eac9b2df8543d8aea6c60..f7248662cf8cb5300e0977553c86b88420d3e5e2 100644 --- a/go.sum +++ b/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= diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index a42b1e7aa0ac9ac474de626b55ceb3a91824cdff..2018c0b644c7d68092c7f4bf990f0bb5c119c28e 100644 --- a/internal/ui/model/keys.go +++ b/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"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a8719f0fd041e56847bbbbf4d46b8846b5e520a4..05fa503eb40b60560fb6cf185a16ec5342144349 100644 --- a/internal/ui/model/ui.go +++ b/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 {