detached ab3d4c5

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 {
1212 }
1313
1414 func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
15+ if len(pending) == 0 {
16+ return text, nil
17+ }
18+
1519 out := text
1620 images := make([]provider.ImageBlock, 0, len(pending))
1721 for _, item := range pending {
1822 if item.Marker == "" || !strings.Contains(out, item.Marker) {
1923 continue
2024 }
21- out = strings.ReplaceAll(out, item.Marker, " ")
25+ out = removeClipboardMarker(out, item.Marker)
2226 images = append(images, item.Image)
2327 }
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'
2661 }
@@ -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 := text19 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 continue23 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, images29+}
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.
9090 t.Fatalf("len(images) = %d, want 1", len(images))
9191 }
9292 }
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) {
7474 return "", nil, false, err
7575 }
7676 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+ }
7884 }
7985
8086 data, err := os.ReadFile(path)
@@ -92,6 +98,12 @@ func writeClipboardImageData(path string) (string, error) {
9298 if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
9399 return "", nil
94100 }
101+ if kind, ok := clipboardImageKind(trimmed); ok {
102+ return kind, nil
103+ }
104+ if path, ok := findClipboardImagePath(trimmed); ok {
105+ return path, nil
106+ }
95107 if trimmed == "" {
96108 return "", fmt.Errorf("osascript failed: %w", err)
97109 }
@@ -100,9 +112,86 @@ func writeClipboardImageData(path string) (string, error) {
100112 if trimmed == "NO_IMAGE" {
101113 return "", nil
102114 }
115+ if kind, ok := clipboardImageKind(trimmed); ok {
116+ return kind, nil
117+ }
103118 return trimmed, nil
104119 }
105120
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+
106195 func convertTIFFFileToPNG(srcPath, dstPath string) error {
107196 in, err := os.Open(srcPath)
108197 if err != nil {
@@ -74,7 +74,13 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
74 return "", nil, false, err74 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 "", nil99 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 "", nil113 return "", nil
102 }114 }
115+ if kind, ok := clipboardImageKind(trimmed); ok {
116+ return kind, nil
117+ }
103 return trimmed, nil118 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
631631 }
632632 row := func(content string) string {
633633 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
635637 }
636638 var bubble []string
637639 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) + content633 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 []string638 var bubble []string
637 for _, c := range m.Content {639 for _, c := range m.Content {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close