fix: harden clipboard image paste
Preserve prompt whitespace when stripping clipboard image markers, accept osascript warning noise, support clipboard image file paths, and keep the existing user bubble rendering. Co-authored-by: mi-skam <40042054+mi-skam@users.noreply.github.com>
- commit
- ab3d4c5ced74618bfc505da79a8fa0c0f23d8b63
- author
- patriceckhart · · 2026-06-22 05:47 UTC
- parent
- 1f663cb
- signature
- unsigned
4 files · +159 -5
M packages/agent/modes/clipboard_images.go +38 -3
| @@ -12,15 +12,50 @@ type clipboardImageAttachment struct { | |||
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | 14 | func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) { | |
| 15 | + | if len(pending) == 0 { | |
| 16 | + | return text, nil | |
| 17 | + | } | |
| 18 | + | ||
| 15 | 19 | out := text | |
| 16 | 20 | images := make([]provider.ImageBlock, 0, len(pending)) | |
| 17 | 21 | for _, item := range pending { | |
| 18 | 22 | if item.Marker == "" || !strings.Contains(out, item.Marker) { | |
| 19 | 23 | continue | |
| 20 | 24 | } | |
| 21 | - | out = strings.ReplaceAll(out, item.Marker, " ") | |
| 25 | + | out = removeClipboardMarker(out, item.Marker) | |
| 22 | 26 | images = append(images, item.Image) | |
| 23 | 27 | } | |
| 24 | - | out = strings.Join(strings.Fields(out), " ") | |
| 25 | - | return out, images | |
| 28 | + | return strings.TrimSpace(out), images | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func removeClipboardMarker(text, marker string) string { | |
| 32 | + | for { | |
| 33 | + | idx := strings.Index(text, marker) | |
| 34 | + | if idx < 0 { | |
| 35 | + | return text | |
| 36 | + | } | |
| 37 | + | end := idx + len(marker) | |
| 38 | + | prevInline := idx > 0 && isInlineWhitespace(text[idx-1]) | |
| 39 | + | nextInline := end < len(text) && isInlineWhitespace(text[end]) | |
| 40 | + | prevLineBreak := idx == 0 || idx > 0 && isLineBreak(text[idx-1]) | |
| 41 | + | nextLineBreak := end == len(text) || end < len(text) && isLineBreak(text[end]) | |
| 42 | + | switch { | |
| 43 | + | case prevInline && nextInline: | |
| 44 | + | text = text[:idx] + text[end+1:] | |
| 45 | + | case prevInline && nextLineBreak: | |
| 46 | + | text = text[:idx-1] + text[end:] | |
| 47 | + | case prevLineBreak && nextInline: | |
| 48 | + | text = text[:idx] + text[end+1:] | |
| 49 | + | default: | |
| 50 | + | text = text[:idx] + text[end:] | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func isInlineWhitespace(b byte) bool { | |
| 56 | + | return b == ' ' || b == '\t' | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func isLineBreak(b byte) bool { | |
| 60 | + | return b == '\n' || b == '\r' | |
| 26 | 61 | } | |
| @@ -12,15 +12,50 @@ type clipboardImageAttachment struct { | |||||
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| 14 | func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) { | 14 | func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) { | ||
| 15 | + | if len(pending) == 0 { | |||
| 16 | + | return text, nil | |||
| 17 | + | } | |||
| 18 | + | ||||
| 15 | out := text | 19 | out := text | ||
| 16 | images := make([]provider.ImageBlock, 0, len(pending)) | 20 | images := make([]provider.ImageBlock, 0, len(pending)) | ||
| 17 | for _, item := range pending { | 21 | for _, item := range pending { | ||
| 18 | if item.Marker == "" || !strings.Contains(out, item.Marker) { | 22 | if item.Marker == "" || !strings.Contains(out, item.Marker) { | ||
| 19 | continue | 23 | continue | ||
| 20 | } | 24 | } | ||
| 21 | - | out = strings.ReplaceAll(out, item.Marker, " ") | 25 | + | out = removeClipboardMarker(out, item.Marker) |
| 22 | images = append(images, item.Image) | 26 | images = append(images, item.Image) | ||
| 23 | } | 27 | } | ||
| 24 | - | out = strings.Join(strings.Fields(out), " ") | 28 | + | return strings.TrimSpace(out), images |
| 25 | - | return out, images | 29 | + | } |
| 30 | + | ||||
| 31 | + | func removeClipboardMarker(text, marker string) string { | |||
| 32 | + | for { | |||
| 33 | + | idx := strings.Index(text, marker) | |||
| 34 | + | if idx < 0 { | |||
| 35 | + | return text | |||
| 36 | + | } | |||
| 37 | + | end := idx + len(marker) | |||
| 38 | + | prevInline := idx > 0 && isInlineWhitespace(text[idx-1]) | |||
| 39 | + | nextInline := end < len(text) && isInlineWhitespace(text[end]) | |||
| 40 | + | prevLineBreak := idx == 0 || idx > 0 && isLineBreak(text[idx-1]) | |||
| 41 | + | nextLineBreak := end == len(text) || end < len(text) && isLineBreak(text[end]) | |||
| 42 | + | switch { | |||
| 43 | + | case prevInline && nextInline: | |||
| 44 | + | text = text[:idx] + text[end+1:] | |||
| 45 | + | case prevInline && nextLineBreak: | |||
| 46 | + | text = text[:idx-1] + text[end:] | |||
| 47 | + | case prevLineBreak && nextInline: | |||
| 48 | + | text = text[:idx] + text[end+1:] | |||
| 49 | + | default: | |||
| 50 | + | text = text[:idx] + text[end:] | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func isInlineWhitespace(b byte) bool { | |||
| 56 | + | return b == ' ' || b == '\t' | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func isLineBreak(b byte) bool { | |||
| 60 | + | return b == '\n' || b == '\r' | |||
| 26 | } | 61 | } | ||
M packages/agent/modes/clipboard_images_test.go +28 -0
| @@ -90,3 +90,31 @@ func TestPreparePromptWithClipboardImagesDuplicateMarkerAttachesOnce(t *testing. | |||
| 90 | 90 | t.Fatalf("len(images) = %d, want 1", len(images)) | |
| 91 | 91 | } | |
| 92 | 92 | } | |
| 93 | + | ||
| 94 | + | func TestPreparePromptWithClipboardImagesNoPendingPreservesWhitespace(t *testing.T) { | |
| 95 | + | input := "please review:\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n" | |
| 96 | + | ||
| 97 | + | text, images := preparePromptWithClipboardImages(input, nil) | |
| 98 | + | ||
| 99 | + | if text != input { | |
| 100 | + | t.Fatalf("text changed without pending images:\n got %q\nwant %q", text, input) | |
| 101 | + | } | |
| 102 | + | if images != nil { | |
| 103 | + | t.Fatalf("images = %#v, want nil", images) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestPreparePromptWithClipboardImagesPreservesMultilinePrompt(t *testing.T) { | |
| 108 | + | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |
| 109 | + | input := "compare this image [clipboard image #1]\n\nwith this code:\n\treturn 1" | |
| 110 | + | want := "compare this image\n\nwith this code:\n\treturn 1" | |
| 111 | + | ||
| 112 | + | text, images := preparePromptWithClipboardImages(input, pending) | |
| 113 | + | ||
| 114 | + | if text != want { | |
| 115 | + | t.Fatalf("text = %q, want %q", text, want) | |
| 116 | + | } | |
| 117 | + | if len(images) != 1 { | |
| 118 | + | t.Fatalf("len(images) = %d, want 1", len(images)) | |
| 119 | + | } | |
| 120 | + | } | |
| @@ -90,3 +90,31 @@ func TestPreparePromptWithClipboardImagesDuplicateMarkerAttachesOnce(t *testing. | |||||
| 90 | t.Fatalf("len(images) = %d, want 1", len(images)) | 90 | t.Fatalf("len(images) = %d, want 1", len(images)) | ||
| 91 | } | 91 | } | ||
| 92 | } | 92 | } | ||
| 93 | + | ||||
| 94 | + | func TestPreparePromptWithClipboardImagesNoPendingPreservesWhitespace(t *testing.T) { | |||
| 95 | + | input := "please review:\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n" | |||
| 96 | + | ||||
| 97 | + | text, images := preparePromptWithClipboardImages(input, nil) | |||
| 98 | + | ||||
| 99 | + | if text != input { | |||
| 100 | + | t.Fatalf("text changed without pending images:\n got %q\nwant %q", text, input) | |||
| 101 | + | } | |||
| 102 | + | if images != nil { | |||
| 103 | + | t.Fatalf("images = %#v, want nil", images) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestPreparePromptWithClipboardImagesPreservesMultilinePrompt(t *testing.T) { | |||
| 108 | + | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |||
| 109 | + | input := "compare this image [clipboard image #1]\n\nwith this code:\n\treturn 1" | |||
| 110 | + | want := "compare this image\n\nwith this code:\n\treturn 1" | |||
| 111 | + | ||||
| 112 | + | text, images := preparePromptWithClipboardImages(input, pending) | |||
| 113 | + | ||||
| 114 | + | if text != want { | |||
| 115 | + | t.Fatalf("text = %q, want %q", text, want) | |||
| 116 | + | } | |||
| 117 | + | if len(images) != 1 { | |||
| 118 | + | t.Fatalf("len(images) = %d, want 1", len(images)) | |||
| 119 | + | } | |||
| 120 | + | } | |||
M packages/tui/clipboard_darwin.go +90 -1
| @@ -74,7 +74,13 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 74 | 74 | return "", nil, false, err | |
| 75 | 75 | } | |
| 76 | 76 | default: | |
| 77 | - | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |
| 77 | + | clipPath, ok := findClipboardImagePath(kind) | |
| 78 | + | if !ok { | |
| 79 | + | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |
| 80 | + | } | |
| 81 | + | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | |
| 82 | + | return "", nil, false, err | |
| 83 | + | } | |
| 78 | 84 | } | |
| 79 | 85 | ||
| 80 | 86 | data, err := os.ReadFile(path) | |
| @@ -92,6 +98,12 @@ func writeClipboardImageData(path string) (string, error) { | |||
| 92 | 98 | if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") { | |
| 93 | 99 | return "", nil | |
| 94 | 100 | } | |
| 101 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |
| 102 | + | return kind, nil | |
| 103 | + | } | |
| 104 | + | if path, ok := findClipboardImagePath(trimmed); ok { | |
| 105 | + | return path, nil | |
| 106 | + | } | |
| 95 | 107 | if trimmed == "" { | |
| 96 | 108 | return "", fmt.Errorf("osascript failed: %w", err) | |
| 97 | 109 | } | |
| @@ -100,9 +112,86 @@ func writeClipboardImageData(path string) (string, error) { | |||
| 100 | 112 | if trimmed == "NO_IMAGE" { | |
| 101 | 113 | return "", nil | |
| 102 | 114 | } | |
| 115 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |
| 116 | + | return kind, nil | |
| 117 | + | } | |
| 103 | 118 | return trimmed, nil | |
| 104 | 119 | } | |
| 105 | 120 | ||
| 121 | + | func clipboardImageKind(s string) (string, bool) { | |
| 122 | + | for _, line := range strings.Split(s, "\n") { | |
| 123 | + | switch strings.TrimSpace(line) { | |
| 124 | + | case "png": | |
| 125 | + | return "png", true | |
| 126 | + | case "tiff": | |
| 127 | + | return "tiff", true | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return "", false | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | |
| 134 | + | switch strings.ToLower(filepath.Ext(srcPath)) { | |
| 135 | + | case ".png": | |
| 136 | + | data, err := os.ReadFile(srcPath) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | return os.WriteFile(dstPath, data, 0o600) | |
| 141 | + | case ".tif", ".tiff": | |
| 142 | + | return convertTIFFFileToPNG(srcPath, dstPath) | |
| 143 | + | default: | |
| 144 | + | return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func findClipboardImagePath(s string) (string, bool) { | |
| 149 | + | if p, ok := clipboardImagePath(s); ok { | |
| 150 | + | return p, true | |
| 151 | + | } | |
| 152 | + | for _, ext := range []string{".png", ".tiff", ".tif"} { | |
| 153 | + | lower := strings.ToLower(s) | |
| 154 | + | end := strings.Index(lower, ext) | |
| 155 | + | if end < 0 { | |
| 156 | + | continue | |
| 157 | + | } | |
| 158 | + | end += len(ext) | |
| 159 | + | start := strings.LastIndex(s[:end], "/") | |
| 160 | + | if start < 0 { | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | for start > 0 { | |
| 164 | + | c := s[start-1] | |
| 165 | + | if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' { | |
| 166 | + | break | |
| 167 | + | } | |
| 168 | + | start-- | |
| 169 | + | } | |
| 170 | + | if p, ok := clipboardImagePath(s[start:end]); ok { | |
| 171 | + | return p, true | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return "", false | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func clipboardImagePath(s string) (string, bool) { | |
| 178 | + | p := strings.TrimSpace(s) | |
| 179 | + | p = strings.Trim(p, "'\"") | |
| 180 | + | if p == "" { | |
| 181 | + | return "", false | |
| 182 | + | } | |
| 183 | + | info, err := os.Stat(p) | |
| 184 | + | if err != nil || info.IsDir() { | |
| 185 | + | return "", false | |
| 186 | + | } | |
| 187 | + | switch strings.ToLower(filepath.Ext(p)) { | |
| 188 | + | case ".png", ".tif", ".tiff": | |
| 189 | + | return p, true | |
| 190 | + | default: | |
| 191 | + | return "", false | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 106 | 195 | func convertTIFFFileToPNG(srcPath, dstPath string) error { | |
| 107 | 196 | in, err := os.Open(srcPath) | |
| 108 | 197 | if err != nil { | |
| @@ -74,7 +74,13 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||||
| 74 | return "", nil, false, err | 74 | return "", nil, false, err | ||
| 75 | } | 75 | } | ||
| 76 | default: | 76 | default: | ||
| 77 | - | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | 77 | + | clipPath, ok := findClipboardImagePath(kind) |
| 78 | + | if !ok { | |||
| 79 | + | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |||
| 80 | + | } | |||
| 81 | + | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | |||
| 82 | + | return "", nil, false, err | |||
| 83 | + | } | |||
| 78 | } | 84 | } | ||
| 79 | 85 | ||||
| 80 | data, err := os.ReadFile(path) | 86 | data, err := os.ReadFile(path) | ||
| @@ -92,6 +98,12 @@ func writeClipboardImageData(path string) (string, error) { | |||||
| 92 | if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") { | 98 | if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") { | ||
| 93 | return "", nil | 99 | return "", nil | ||
| 94 | } | 100 | } | ||
| 101 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |||
| 102 | + | return kind, nil | |||
| 103 | + | } | |||
| 104 | + | if path, ok := findClipboardImagePath(trimmed); ok { | |||
| 105 | + | return path, nil | |||
| 106 | + | } | |||
| 95 | if trimmed == "" { | 107 | if trimmed == "" { | ||
| 96 | return "", fmt.Errorf("osascript failed: %w", err) | 108 | return "", fmt.Errorf("osascript failed: %w", err) | ||
| 97 | } | 109 | } | ||
| @@ -100,9 +112,86 @@ func writeClipboardImageData(path string) (string, error) { | |||||
| 100 | if trimmed == "NO_IMAGE" { | 112 | if trimmed == "NO_IMAGE" { | ||
| 101 | return "", nil | 113 | return "", nil | ||
| 102 | } | 114 | } | ||
| 115 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |||
| 116 | + | return kind, nil | |||
| 117 | + | } | |||
| 103 | return trimmed, nil | 118 | return trimmed, nil | ||
| 104 | } | 119 | } | ||
| 105 | 120 | ||||
| 121 | + | func clipboardImageKind(s string) (string, bool) { | |||
| 122 | + | for _, line := range strings.Split(s, "\n") { | |||
| 123 | + | switch strings.TrimSpace(line) { | |||
| 124 | + | case "png": | |||
| 125 | + | return "png", true | |||
| 126 | + | case "tiff": | |||
| 127 | + | return "tiff", true | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return "", false | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | |||
| 134 | + | switch strings.ToLower(filepath.Ext(srcPath)) { | |||
| 135 | + | case ".png": | |||
| 136 | + | data, err := os.ReadFile(srcPath) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | return os.WriteFile(dstPath, data, 0o600) | |||
| 141 | + | case ".tif", ".tiff": | |||
| 142 | + | return convertTIFFFileToPNG(srcPath, dstPath) | |||
| 143 | + | default: | |||
| 144 | + | return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func findClipboardImagePath(s string) (string, bool) { | |||
| 149 | + | if p, ok := clipboardImagePath(s); ok { | |||
| 150 | + | return p, true | |||
| 151 | + | } | |||
| 152 | + | for _, ext := range []string{".png", ".tiff", ".tif"} { | |||
| 153 | + | lower := strings.ToLower(s) | |||
| 154 | + | end := strings.Index(lower, ext) | |||
| 155 | + | if end < 0 { | |||
| 156 | + | continue | |||
| 157 | + | } | |||
| 158 | + | end += len(ext) | |||
| 159 | + | start := strings.LastIndex(s[:end], "/") | |||
| 160 | + | if start < 0 { | |||
| 161 | + | continue | |||
| 162 | + | } | |||
| 163 | + | for start > 0 { | |||
| 164 | + | c := s[start-1] | |||
| 165 | + | if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' { | |||
| 166 | + | break | |||
| 167 | + | } | |||
| 168 | + | start-- | |||
| 169 | + | } | |||
| 170 | + | if p, ok := clipboardImagePath(s[start:end]); ok { | |||
| 171 | + | return p, true | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | return "", false | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func clipboardImagePath(s string) (string, bool) { | |||
| 178 | + | p := strings.TrimSpace(s) | |||
| 179 | + | p = strings.Trim(p, "'\"") | |||
| 180 | + | if p == "" { | |||
| 181 | + | return "", false | |||
| 182 | + | } | |||
| 183 | + | info, err := os.Stat(p) | |||
| 184 | + | if err != nil || info.IsDir() { | |||
| 185 | + | return "", false | |||
| 186 | + | } | |||
| 187 | + | switch strings.ToLower(filepath.Ext(p)) { | |||
| 188 | + | case ".png", ".tif", ".tiff": | |||
| 189 | + | return p, true | |||
| 190 | + | default: | |||
| 191 | + | return "", false | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 106 | func convertTIFFFileToPNG(srcPath, dstPath string) error { | 195 | func convertTIFFFileToPNG(srcPath, dstPath string) error { | ||
| 107 | in, err := os.Open(srcPath) | 196 | in, err := os.Open(srcPath) | ||
| 108 | if err != nil { | 197 | if err != nil { | ||
M packages/tui/view.go +3 -1
| @@ -631,7 +631,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 631 | 631 | } | |
| 632 | 632 | row := func(content string) string { | |
| 633 | 633 | inner := strings.Repeat(" ", leftGutter) + content | |
| 634 | - | return v.Theme.UserBubbleRow(inner, width) | |
| 634 | + | padded := v.Theme.UserBubble(inner, width-2) | |
| 635 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 636 | + | return bar + padded | |
| 635 | 637 | } | |
| 636 | 638 | var bubble []string | |
| 637 | 639 | for _, c := range m.Content { | |
| @@ -631,7 +631,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 631 | } | 631 | } | ||
| 632 | row := func(content string) string { | 632 | row := func(content string) string { | ||
| 633 | inner := strings.Repeat(" ", leftGutter) + content | 633 | inner := strings.Repeat(" ", leftGutter) + content | ||
| 634 | - | return v.Theme.UserBubbleRow(inner, width) | 634 | + | padded := v.Theme.UserBubble(inner, width-2) |
| 635 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |||
| 636 | + | return bar + padded | |||
| 635 | } | 637 | } | ||
| 636 | var bubble []string | 638 | var bubble []string | ||
| 637 | for _, c := range m.Content { | 639 | for _, c := range m.Content { | ||