feat(tui): paste images from Linux clipboards
- commit
- 3346322247ced76374f8d386de5de0e188006b2e
- author
- brice · · 2026-08-04 09:21 UTC
- parent
- 0a849d0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +264 -4
M README.md +5 -1
| @@ -759,6 +759,10 @@ Opening `/model` refreshes the router and lists every loaded model under provide | |||
| 759 | 759 | ||
| 760 | 760 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | |
| 761 | 761 | ||
| 762 | + | ## Clipboard images | |
| 763 | + | ||
| 764 | + | In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available. | |
| 765 | + | ||
| 762 | 766 | ## Inline images | |
| 763 | 767 | ||
| 764 | 768 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | |
| @@ -863,7 +867,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 863 | 867 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | |
| 864 | 868 | | `ctrl+d` | Exit on empty input. | | |
| 865 | 869 | | `ctrl+l` | Redraw the screen. | | |
| 866 | - | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | | |
| 870 | + | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | |
| 867 | 871 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 868 | 872 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | |
| 869 | 873 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| @@ -759,6 +759,10 @@ Opening `/model` refreshes the router and lists every loaded model under provide | |||||
| 759 | 759 | ||||
| 760 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | 760 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | ||
| 761 | 761 | ||||
| 762 | + | ## Clipboard images | |||
| 763 | + | ||||
| 764 | + | In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available. | |||
| 765 | + | ||||
| 762 | ## Inline images | 766 | ## Inline images | ||
| 763 | 767 | ||||
| 764 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | 768 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | ||
| @@ -863,7 +867,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 863 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | 867 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | ||
| 864 | | `ctrl+d` | Exit on empty input. | | 868 | | `ctrl+d` | Exit on empty input. | | ||
| 865 | | `ctrl+l` | Redraw the screen. | | 869 | | `ctrl+l` | Redraw the screen. | | ||
| 866 | - | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | | 870 | + | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | |
| 867 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 871 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | ||
| 868 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | 872 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | ||
| 869 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 873 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
M packages/agent/modes/interactive.go +2 -2
| @@ -2767,7 +2767,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2767 | 2767 | } | |
| 2768 | 2768 | ||
| 2769 | 2769 | func (i *Interactive) pasteClipboard() { | |
| 2770 | - | _, data, ok, err := tui.ReadClipboardImagePNG() | |
| 2770 | + | image, ok, err := tui.ReadClipboardImage() | |
| 2771 | 2771 | if err != nil { | |
| 2772 | 2772 | i.mu.Lock() | |
| 2773 | 2773 | i.statusErr = "clipboard paste failed: " + err.Error() | |
| @@ -2780,7 +2780,7 @@ func (i *Interactive) pasteClipboard() { | |||
| 2780 | 2780 | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | |
| 2781 | 2781 | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | |
| 2782 | 2782 | Marker: marker, | |
| 2783 | - | Image: provider.ImageBlock{MimeType: "image/png", Data: data}, | |
| 2783 | + | Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data}, | |
| 2784 | 2784 | }) | |
| 2785 | 2785 | i.ed.Insert(marker + " ") | |
| 2786 | 2786 | i.statusOK = "" | |
| @@ -2767,7 +2767,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2767 | } | 2767 | } | ||
| 2768 | 2768 | ||||
| 2769 | func (i *Interactive) pasteClipboard() { | 2769 | func (i *Interactive) pasteClipboard() { | ||
| 2770 | - | _, data, ok, err := tui.ReadClipboardImagePNG() | 2770 | + | image, ok, err := tui.ReadClipboardImage() |
| 2771 | if err != nil { | 2771 | if err != nil { | ||
| 2772 | i.mu.Lock() | 2772 | i.mu.Lock() | ||
| 2773 | i.statusErr = "clipboard paste failed: " + err.Error() | 2773 | i.statusErr = "clipboard paste failed: " + err.Error() | ||
| @@ -2780,7 +2780,7 @@ func (i *Interactive) pasteClipboard() { | |||||
| 2780 | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | 2780 | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | ||
| 2781 | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | 2781 | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | ||
| 2782 | Marker: marker, | 2782 | Marker: marker, | ||
| 2783 | - | Image: provider.ImageBlock{MimeType: "image/png", Data: data}, | 2783 | + | Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data}, |
| 2784 | }) | 2784 | }) | ||
| 2785 | i.ed.Insert(marker + " ") | 2785 | i.ed.Insert(marker + " ") | ||
| 2786 | i.statusOK = "" | 2786 | i.statusOK = "" | ||
A packages/tui/clipboard.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | // ClipboardImage is an image read from the system clipboard. | |
| 4 | + | type ClipboardImage struct { | |
| 5 | + | MimeType string | |
| 6 | + | Data []byte | |
| 7 | + | } | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | // ClipboardImage is an image read from the system clipboard. | |||
| 4 | + | type ClipboardImage struct { | |||
| 5 | + | MimeType string | |||
| 6 | + | Data []byte | |||
| 7 | + | } | |||
M packages/tui/clipboard_darwin.go +11 -0
| @@ -47,6 +47,17 @@ on run argv | |||
| 47 | 47 | end run | |
| 48 | 48 | ` | |
| 49 | 49 | ||
| 50 | + | func ReadClipboardImage() (ClipboardImage, bool, error) { | |
| 51 | + | _, data, ok, err := ReadClipboardImagePNG() | |
| 52 | + | if err != nil { | |
| 53 | + | return ClipboardImage{}, false, err | |
| 54 | + | } | |
| 55 | + | if !ok { | |
| 56 | + | return ClipboardImage{}, false, nil | |
| 57 | + | } | |
| 58 | + | return ClipboardImage{MimeType: "image/png", Data: data}, true, nil | |
| 59 | + | } | |
| 60 | + | ||
| 50 | 61 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 51 | 62 | dir := clipboardImageDir() | |
| 52 | 63 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| @@ -47,6 +47,17 @@ on run argv | |||||
| 47 | end run | 47 | end run | ||
| 48 | ` | 48 | ` | ||
| 49 | 49 | ||||
| 50 | + | func ReadClipboardImage() (ClipboardImage, bool, error) { | |||
| 51 | + | _, data, ok, err := ReadClipboardImagePNG() | |||
| 52 | + | if err != nil { | |||
| 53 | + | return ClipboardImage{}, false, err | |||
| 54 | + | } | |||
| 55 | + | if !ok { | |||
| 56 | + | return ClipboardImage{}, false, nil | |||
| 57 | + | } | |||
| 58 | + | return ClipboardImage{MimeType: "image/png", Data: data}, true, nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 50 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | 61 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | ||
| 51 | dir := clipboardImageDir() | 62 | dir := clipboardImageDir() | ||
| 52 | if err := os.MkdirAll(dir, 0o700); err != nil { | 63 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
A packages/tui/clipboard_linux.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const clipboardImageCommandTimeout = 5 * time.Second | |
| 14 | + | ||
| 15 | + | var supportedClipboardImageMIMEs = []string{ | |
| 16 | + | "image/png", | |
| 17 | + | "image/jpeg", | |
| 18 | + | "image/gif", | |
| 19 | + | "image/webp", | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type clipboardImageCommand struct { | |
| 23 | + | name string | |
| 24 | + | args []string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ReadClipboardImage reads an image from the Wayland or X11 system clipboard. | |
| 28 | + | // Clipboard helper failures are intentionally treated as an empty clipboard so | |
| 29 | + | // that optional desktop integrations do not affect paste handling. | |
| 30 | + | func ReadClipboardImage() (ClipboardImage, bool, error) { | |
| 31 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 32 | + | if image, ok := readClipboardImageWayland(); ok { | |
| 33 | + | return image, true, nil | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | if os.Getenv("DISPLAY") != "" { | |
| 37 | + | if image, ok := readClipboardImageX11(); ok { | |
| 38 | + | return image, true, nil | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return ClipboardImage{}, false, nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // ReadClipboardImagePNG preserves the legacy Linux behavior. Linux image | |
| 45 | + | // reads retain their clipboard MIME type through ReadClipboardImage instead. | |
| 46 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 47 | + | return "", nil, false, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func readClipboardImageWayland() (ClipboardImage, bool) { | |
| 51 | + | types, ok := runClipboardImageCommand(clipboardImageCommand{ | |
| 52 | + | name: "wl-paste", | |
| 53 | + | args: []string{"--list-types"}, | |
| 54 | + | }) | |
| 55 | + | if !ok { | |
| 56 | + | return ClipboardImage{}, false | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | available := make(map[string]bool) | |
| 60 | + | for _, line := range strings.Split(string(types), "\n") { | |
| 61 | + | mime := strings.ToLower(strings.TrimSpace(line)) | |
| 62 | + | if isSupportedClipboardImageMIME(mime) { | |
| 63 | + | available[mime] = true | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | for _, mime := range supportedClipboardImageMIMEs { | |
| 67 | + | if !available[mime] { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | data, ok := runClipboardImageCommand(clipboardImageCommand{ | |
| 71 | + | name: "wl-paste", | |
| 72 | + | args: []string{"--type", mime}, | |
| 73 | + | }) | |
| 74 | + | if ok { | |
| 75 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return ClipboardImage{}, false | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func readClipboardImageX11() (ClipboardImage, bool) { | |
| 82 | + | for _, mime := range supportedClipboardImageMIMEs { | |
| 83 | + | data, ok := runClipboardImageCommand(clipboardImageCommand{ | |
| 84 | + | name: "xclip", | |
| 85 | + | args: []string{"-selection", "clipboard", "-out", "-target", mime}, | |
| 86 | + | }) | |
| 87 | + | if ok { | |
| 88 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return ClipboardImage{}, false | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func isSupportedClipboardImageMIME(mime string) bool { | |
| 95 | + | for _, supported := range supportedClipboardImageMIMEs { | |
| 96 | + | if mime == supported { | |
| 97 | + | return true | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return false | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func runClipboardImageCommand(command clipboardImageCommand) ([]byte, bool) { | |
| 104 | + | path, err := exec.LookPath(command.name) | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, false | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | ctx, cancel := context.WithTimeout(context.Background(), clipboardImageCommandTimeout) | |
| 110 | + | defer cancel() | |
| 111 | + | data, err := exec.CommandContext(ctx, path, command.args...).Output() | |
| 112 | + | if err != nil || len(data) == 0 { | |
| 113 | + | return nil, false | |
| 114 | + | } | |
| 115 | + | return data, true | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const clipboardImageCommandTimeout = 5 * time.Second | |||
| 14 | + | ||||
| 15 | + | var supportedClipboardImageMIMEs = []string{ | |||
| 16 | + | "image/png", | |||
| 17 | + | "image/jpeg", | |||
| 18 | + | "image/gif", | |||
| 19 | + | "image/webp", | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type clipboardImageCommand struct { | |||
| 23 | + | name string | |||
| 24 | + | args []string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // ReadClipboardImage reads an image from the Wayland or X11 system clipboard. | |||
| 28 | + | // Clipboard helper failures are intentionally treated as an empty clipboard so | |||
| 29 | + | // that optional desktop integrations do not affect paste handling. | |||
| 30 | + | func ReadClipboardImage() (ClipboardImage, bool, error) { | |||
| 31 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |||
| 32 | + | if image, ok := readClipboardImageWayland(); ok { | |||
| 33 | + | return image, true, nil | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | if os.Getenv("DISPLAY") != "" { | |||
| 37 | + | if image, ok := readClipboardImageX11(); ok { | |||
| 38 | + | return image, true, nil | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | return ClipboardImage{}, false, nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // ReadClipboardImagePNG preserves the legacy Linux behavior. Linux image | |||
| 45 | + | // reads retain their clipboard MIME type through ReadClipboardImage instead. | |||
| 46 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 47 | + | return "", nil, false, nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func readClipboardImageWayland() (ClipboardImage, bool) { | |||
| 51 | + | types, ok := runClipboardImageCommand(clipboardImageCommand{ | |||
| 52 | + | name: "wl-paste", | |||
| 53 | + | args: []string{"--list-types"}, | |||
| 54 | + | }) | |||
| 55 | + | if !ok { | |||
| 56 | + | return ClipboardImage{}, false | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | available := make(map[string]bool) | |||
| 60 | + | for _, line := range strings.Split(string(types), "\n") { | |||
| 61 | + | mime := strings.ToLower(strings.TrimSpace(line)) | |||
| 62 | + | if isSupportedClipboardImageMIME(mime) { | |||
| 63 | + | available[mime] = true | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | for _, mime := range supportedClipboardImageMIMEs { | |||
| 67 | + | if !available[mime] { | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | data, ok := runClipboardImageCommand(clipboardImageCommand{ | |||
| 71 | + | name: "wl-paste", | |||
| 72 | + | args: []string{"--type", mime}, | |||
| 73 | + | }) | |||
| 74 | + | if ok { | |||
| 75 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | return ClipboardImage{}, false | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func readClipboardImageX11() (ClipboardImage, bool) { | |||
| 82 | + | for _, mime := range supportedClipboardImageMIMEs { | |||
| 83 | + | data, ok := runClipboardImageCommand(clipboardImageCommand{ | |||
| 84 | + | name: "xclip", | |||
| 85 | + | args: []string{"-selection", "clipboard", "-out", "-target", mime}, | |||
| 86 | + | }) | |||
| 87 | + | if ok { | |||
| 88 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | return ClipboardImage{}, false | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func isSupportedClipboardImageMIME(mime string) bool { | |||
| 95 | + | for _, supported := range supportedClipboardImageMIMEs { | |||
| 96 | + | if mime == supported { | |||
| 97 | + | return true | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | return false | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func runClipboardImageCommand(command clipboardImageCommand) ([]byte, bool) { | |||
| 104 | + | path, err := exec.LookPath(command.name) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return nil, false | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | ctx, cancel := context.WithTimeout(context.Background(), clipboardImageCommandTimeout) | |||
| 110 | + | defer cancel() | |||
| 111 | + | data, err := exec.CommandContext(ctx, path, command.args...).Output() | |||
| 112 | + | if err != nil || len(data) == 0 { | |||
| 113 | + | return nil, false | |||
| 114 | + | } | |||
| 115 | + | return data, true | |||
| 116 | + | } | |||
A packages/tui/clipboard_linux_test.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestReadClipboardImageWaylandUsesMIMEPriority(t *testing.T) { | |
| 12 | + | dir := t.TempDir() | |
| 13 | + | state := filepath.Join(dir, "listed") | |
| 14 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |
| 15 | + | if [ "$1" = "--list-types" ]; then | |
| 16 | + | : > "$CLIPBOARD_TEST_STATE" | |
| 17 | + | printf 'image/jpeg\nimage/png\nimage/bmp\n' | |
| 18 | + | exit 0 | |
| 19 | + | fi | |
| 20 | + | if [ "$1" = "--type" ]; then | |
| 21 | + | if [ ! -f "$CLIPBOARD_TEST_STATE" ]; then | |
| 22 | + | exit 1 | |
| 23 | + | fi | |
| 24 | + | case "$2" in | |
| 25 | + | image/png) printf 'png-bytes' ;; | |
| 26 | + | image/jpeg) printf 'jpeg-bytes' ;; | |
| 27 | + | *) exit 1 ;; | |
| 28 | + | esac | |
| 29 | + | exit 0 | |
| 30 | + | fi | |
| 31 | + | exit 1 | |
| 32 | + | `) | |
| 33 | + | t.Setenv("PATH", dir) | |
| 34 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 35 | + | t.Setenv("DISPLAY", "") | |
| 36 | + | t.Setenv("CLIPBOARD_TEST_STATE", state) | |
| 37 | + | ||
| 38 | + | image, ok, err := ReadClipboardImage() | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |
| 41 | + | } | |
| 42 | + | if !ok { | |
| 43 | + | t.Fatal("ReadClipboardImage() reported no image") | |
| 44 | + | } | |
| 45 | + | if image.MimeType != "image/png" { | |
| 46 | + | t.Fatalf("MIME type = %q, want image/png", image.MimeType) | |
| 47 | + | } | |
| 48 | + | if string(image.Data) != "png-bytes" { | |
| 49 | + | t.Fatalf("image data = %q, want png-bytes", image.Data) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestReadClipboardImageFallsBackToX11Helpers(t *testing.T) { | |
| 54 | + | dir := t.TempDir() | |
| 55 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |
| 56 | + | if [ "$1" = "--list-types" ]; then | |
| 57 | + | printf 'text/plain\n' | |
| 58 | + | fi | |
| 59 | + | `) | |
| 60 | + | writeClipboardTestHelper(t, dir, "xclip", `#!/bin/sh | |
| 61 | + | case "$*" in | |
| 62 | + | *"image/gif"*) printf 'gif-bytes' ;; | |
| 63 | + | *) exit 1 ;; | |
| 64 | + | esac | |
| 65 | + | `) | |
| 66 | + | t.Setenv("PATH", dir) | |
| 67 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 68 | + | t.Setenv("DISPLAY", ":test") | |
| 69 | + | ||
| 70 | + | image, ok, err := ReadClipboardImage() | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |
| 73 | + | } | |
| 74 | + | if !ok { | |
| 75 | + | t.Fatal("ReadClipboardImage() reported no image") | |
| 76 | + | } | |
| 77 | + | if image.MimeType != "image/gif" { | |
| 78 | + | t.Fatalf("MIME type = %q, want image/gif", image.MimeType) | |
| 79 | + | } | |
| 80 | + | if string(image.Data) != "gif-bytes" { | |
| 81 | + | t.Fatalf("image data = %q, want gif-bytes", image.Data) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestReadClipboardImageUnavailableIsNoImage(t *testing.T) { | |
| 86 | + | dir := t.TempDir() | |
| 87 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |
| 88 | + | printf 'clipboard helper failure' >&2 | |
| 89 | + | exit 1 | |
| 90 | + | `) | |
| 91 | + | t.Setenv("PATH", dir) | |
| 92 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 93 | + | t.Setenv("DISPLAY", "") | |
| 94 | + | ||
| 95 | + | image, ok, err := ReadClipboardImage() | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatalf("ReadClipboardImage() error = %v, want nil", err) | |
| 98 | + | } | |
| 99 | + | if ok { | |
| 100 | + | t.Fatalf("ReadClipboardImage() returned image %#v", image) | |
| 101 | + | } | |
| 102 | + | if image.MimeType != "" || len(image.Data) != 0 { | |
| 103 | + | t.Fatalf("empty image = %#v", image) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | path, data, ok, err := ReadClipboardImagePNG() | |
| 107 | + | if err != nil || ok || path != "" || data != nil { | |
| 108 | + | t.Fatalf("ReadClipboardImagePNG() = (%q, %v, %v, %v), want empty result", path, data, ok, err) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func writeClipboardTestHelper(t *testing.T, dir, name, script string) { | |
| 113 | + | t.Helper() | |
| 114 | + | path := filepath.Join(dir, name) | |
| 115 | + | if err := os.WriteFile(path, []byte(script), 0o700); err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestReadClipboardImageWaylandUsesMIMEPriority(t *testing.T) { | |||
| 12 | + | dir := t.TempDir() | |||
| 13 | + | state := filepath.Join(dir, "listed") | |||
| 14 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |||
| 15 | + | if [ "$1" = "--list-types" ]; then | |||
| 16 | + | : > "$CLIPBOARD_TEST_STATE" | |||
| 17 | + | printf 'image/jpeg\nimage/png\nimage/bmp\n' | |||
| 18 | + | exit 0 | |||
| 19 | + | fi | |||
| 20 | + | if [ "$1" = "--type" ]; then | |||
| 21 | + | if [ ! -f "$CLIPBOARD_TEST_STATE" ]; then | |||
| 22 | + | exit 1 | |||
| 23 | + | fi | |||
| 24 | + | case "$2" in | |||
| 25 | + | image/png) printf 'png-bytes' ;; | |||
| 26 | + | image/jpeg) printf 'jpeg-bytes' ;; | |||
| 27 | + | *) exit 1 ;; | |||
| 28 | + | esac | |||
| 29 | + | exit 0 | |||
| 30 | + | fi | |||
| 31 | + | exit 1 | |||
| 32 | + | `) | |||
| 33 | + | t.Setenv("PATH", dir) | |||
| 34 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 35 | + | t.Setenv("DISPLAY", "") | |||
| 36 | + | t.Setenv("CLIPBOARD_TEST_STATE", state) | |||
| 37 | + | ||||
| 38 | + | image, ok, err := ReadClipboardImage() | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |||
| 41 | + | } | |||
| 42 | + | if !ok { | |||
| 43 | + | t.Fatal("ReadClipboardImage() reported no image") | |||
| 44 | + | } | |||
| 45 | + | if image.MimeType != "image/png" { | |||
| 46 | + | t.Fatalf("MIME type = %q, want image/png", image.MimeType) | |||
| 47 | + | } | |||
| 48 | + | if string(image.Data) != "png-bytes" { | |||
| 49 | + | t.Fatalf("image data = %q, want png-bytes", image.Data) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestReadClipboardImageFallsBackToX11Helpers(t *testing.T) { | |||
| 54 | + | dir := t.TempDir() | |||
| 55 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |||
| 56 | + | if [ "$1" = "--list-types" ]; then | |||
| 57 | + | printf 'text/plain\n' | |||
| 58 | + | fi | |||
| 59 | + | `) | |||
| 60 | + | writeClipboardTestHelper(t, dir, "xclip", `#!/bin/sh | |||
| 61 | + | case "$*" in | |||
| 62 | + | *"image/gif"*) printf 'gif-bytes' ;; | |||
| 63 | + | *) exit 1 ;; | |||
| 64 | + | esac | |||
| 65 | + | `) | |||
| 66 | + | t.Setenv("PATH", dir) | |||
| 67 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 68 | + | t.Setenv("DISPLAY", ":test") | |||
| 69 | + | ||||
| 70 | + | image, ok, err := ReadClipboardImage() | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |||
| 73 | + | } | |||
| 74 | + | if !ok { | |||
| 75 | + | t.Fatal("ReadClipboardImage() reported no image") | |||
| 76 | + | } | |||
| 77 | + | if image.MimeType != "image/gif" { | |||
| 78 | + | t.Fatalf("MIME type = %q, want image/gif", image.MimeType) | |||
| 79 | + | } | |||
| 80 | + | if string(image.Data) != "gif-bytes" { | |||
| 81 | + | t.Fatalf("image data = %q, want gif-bytes", image.Data) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestReadClipboardImageUnavailableIsNoImage(t *testing.T) { | |||
| 86 | + | dir := t.TempDir() | |||
| 87 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |||
| 88 | + | printf 'clipboard helper failure' >&2 | |||
| 89 | + | exit 1 | |||
| 90 | + | `) | |||
| 91 | + | t.Setenv("PATH", dir) | |||
| 92 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 93 | + | t.Setenv("DISPLAY", "") | |||
| 94 | + | ||||
| 95 | + | image, ok, err := ReadClipboardImage() | |||
| 96 | + | if err != nil { | |||
| 97 | + | t.Fatalf("ReadClipboardImage() error = %v, want nil", err) | |||
| 98 | + | } | |||
| 99 | + | if ok { | |||
| 100 | + | t.Fatalf("ReadClipboardImage() returned image %#v", image) | |||
| 101 | + | } | |||
| 102 | + | if image.MimeType != "" || len(image.Data) != 0 { | |||
| 103 | + | t.Fatalf("empty image = %#v", image) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | path, data, ok, err := ReadClipboardImagePNG() | |||
| 107 | + | if err != nil || ok || path != "" || data != nil { | |||
| 108 | + | t.Fatalf("ReadClipboardImagePNG() = (%q, %v, %v, %v), want empty result", path, data, ok, err) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func writeClipboardTestHelper(t *testing.T, dir, name, script string) { | |||
| 113 | + | t.Helper() | |||
| 114 | + | path := filepath.Join(dir, name) | |||
| 115 | + | if err := os.WriteFile(path, []byte(script), 0o700); err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | } | |||
M packages/tui/clipboard_stub.go +5 -1
| @@ -1,7 +1,11 @@ | |||
| 1 | - | //go:build !darwin | |
| 1 | + | //go:build !darwin && !linux | |
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | + | func ReadClipboardImage() (ClipboardImage, bool, error) { | |
| 6 | + | return ClipboardImage{}, false, nil | |
| 7 | + | } | |
| 8 | + | ||
| 5 | 9 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 6 | 10 | return "", nil, false, nil | |
| 7 | 11 | } | |
| @@ -1,7 +1,11 @@ | |||||
| 1 | - | //go:build !darwin | 1 | + | //go:build !darwin && !linux |
| 2 | 2 | ||||
| 3 | package tui | 3 | package tui | ||
| 4 | 4 | ||||
| 5 | + | func ReadClipboardImage() (ClipboardImage, bool, error) { | |||
| 6 | + | return ClipboardImage{}, false, nil | |||
| 7 | + | } | |||
| 8 | + | ||||
| 5 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | 9 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | ||
| 6 | return "", nil, false, nil | 10 | return "", nil, false, nil | ||
| 7 | } | 11 | } | ||