Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions pkg/tui/components/editor/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ func (b *attachmentBanner) updateHeight() {
b.height = 0
return
}
// Banner takes 1 line when visible
b.height = 1
// Separator line + pills line
b.height = 2
}

func (b *attachmentBanner) View() string {
Expand Down Expand Up @@ -90,7 +90,12 @@ func (b *attachmentBanner) View() string {
Foreground(styles.TextSecondary).
Render(content)

return styles.AttachmentBannerStyle.Render(banner)
divider := styles.BannerSeparatorStyle.Render(strings.Repeat("─", 40))

return lipgloss.JoinVertical(lipgloss.Left,
divider,
styles.AttachmentBannerStyle.Render(banner),
)
}

func (b *attachmentBanner) buildRegions(pills []string, separator string) {
Expand Down
34 changes: 34 additions & 0 deletions pkg/tui/components/editor/clipboard_image_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//go:build darwin

package editor

import (
"fmt"
"os"
"os/exec"
"time"
)

// readClipboardImage attempts to read an image from the macOS clipboard using
// osascript. If the clipboard contains an image, it is written to a temp file
// and the path is returned. If the clipboard has no image (or osascript fails),
// ("", nil) is returned so the caller can fall through to text-paste behaviour.
func readClipboardImage() (string, error) {
tmpPath := fmt.Sprintf("%s/clipboard-image-%d.png", os.TempDir(), time.Now().UnixNano())

script := fmt.Sprintf(`
set imgData to the clipboard as «class PNGf»
set tmpFile to open for access POSIX file %q with write permission
write imgData to tmpFile
close access tmpFile
`, tmpPath)

cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
// No image in clipboard (or osascript unavailable) — silent fallback.
_ = os.Remove(tmpPath) // clean up any partial file
return "", nil
}

return tmpPath, nil
}
9 changes: 9 additions & 0 deletions pkg/tui/components/editor/clipboard_image_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !darwin

package editor

// readClipboardImage is a no-op stub for non-Darwin platforms.
// Image-from-clipboard paste is only supported on macOS.
func readClipboardImage() (string, error) {
return "", nil
}
7 changes: 7 additions & 0 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,13 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
}

func (e *editor) handleClipboardPaste() (layout.Model, tea.Cmd) {
if imgPath, err := readClipboardImage(); err == nil && imgPath != "" {
if attachErr := e.AttachFile(imgPath); attachErr == nil && len(e.attachments) > 0 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Binary PNG data routed through a text-only attachment path — will corrupt image on send

When AttachFile succeeds and isTemp = true is set, the image attachment is handled by the pre-existing collectAttachments() code which reads the temp file and stores it as:

result = append(result, messages.Attachment{
    Name:    strings.TrimPrefix(att.placeholder, "@"),
    Content: string(data),   // raw PNG bytes cast to string
})

collectAttachments was designed for text-paste temp files. For a clipboard PNG, data will be raw binary bytes. Go's string(data) preserves the bytes in memory, but Go's encoding/json encoder replaces invalid UTF-8 sequences with the Unicode replacement character (U+FFFD), corrupting the image before it reaches the model. File-reference attachments use the FilePath field which avoids this issue entirely — clipboard images should follow the same path, or PNG bytes should be base64-encoded into Content.

e.attachments[len(e.attachments)-1].isTemp = true
}
return e, textarea.Blink
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Temp clipboard image file leaked when AttachFile fails

When readClipboardImage() returns a non-empty imgPath and AttachFile subsequently fails (e.g., file too large, validation error), the PNG temp file at imgPath is silently leaked — there is no os.Remove(imgPath) on the error branch, and isTemp is only set on success so collectAttachments cleanup is never triggered either.

if imgPath, err := readClipboardImage(); err == nil && imgPath != "" {
    if attachErr := e.AttachFile(imgPath); attachErr == nil && len(e.attachments) > 0 {
        e.attachments[len(e.attachments)-1].isTemp = true
    }
    return e, textarea.Blink  // temp file leaked if AttachFile failed
}

Fix: add cleanup on the error path:

if attachErr := e.AttachFile(imgPath); attachErr == nil && len(e.attachments) > 0 {
    e.attachments[len(e.attachments)-1].isTemp = true
} else if attachErr != nil {
    _ = os.Remove(imgPath)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Silent failure: AttachFile rejection skips text-paste fallback

The return e, textarea.Blink at line 887 is inside the outer if imgPath != "" block but outside the inner if attachErr == nil block. This means the function always returns early whenever the clipboard contains an image — even if AttachFile fails. The text-paste fallback below is never reached, so if the image is rejected (e.g., >5 MB size limit), the user gets a silent no-op with no feedback and no text-paste attempt.

if imgPath, err := readClipboardImage(); err == nil && imgPath != "" {
    if attachErr := e.AttachFile(imgPath); attachErr == nil && len(e.attachments) > 0 {
        e.attachments[len(e.attachments)-1].isTemp = true
    }
    return e, textarea.Blink  // ← exits even when AttachFile failed
}
// text paste — unreachable when clipboard has an image

Fix: Only return early on successful attachment; fall through to text paste (or show an error) on AttachFile failure:

if imgPath, err := readClipboardImage(); err == nil && imgPath != "" {
    if attachErr := e.AttachFile(imgPath); attachErr == nil && len(e.attachments) > 0 {
        e.attachments[len(e.attachments)-1].isTemp = true
        return e, textarea.Blink
    }
    // AttachFile failed — clean up and fall through to text paste
    _ = os.Remove(imgPath)
}

}

content, err := clipboard.ReadAll()
if err != nil {
slog.Warn("failed to read clipboard", "error", err)
Expand Down
5 changes: 5 additions & 0 deletions pkg/tui/styles/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ var (

AttachmentIconStyle = BaseStyle.
Foreground(Info)

// BannerSeparatorStyle renders the thin horizontal rule drawn above the
// attachment banner pills.
BannerSeparatorStyle = BaseStyle.
Foreground(BorderMuted)
)

// Scrollbar
Expand Down
Loading