detached 365593f

Merge pull request #6 from bnema/feat/clipboard-image-paste

feat(tui): add clipboard image paste
commit
365593ff7f2f68184297174353c3bd3e58da53f9
author
bnema · · 2026-08-04 09:53 UTC
committer
GitHub · · 2026-08-04 09:53 UTC
merge of
16ec11b + 3ece2da · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +417 -18
M README.md +5 -1
@@ -761,6 +761,10 @@ Opening `/model` refreshes the router and lists every loaded model under provide
761761
762762 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.
763763
764+## Clipboard images
765+
766+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. Clipboard images larger than 32 MiB are ignored and use the normal text-paste fallback.
767+
764768 ## Inline images
765769
766770 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:
@@ -865,7 +869,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
865869 | `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. |
866870 | `ctrl+d` | Exit on empty input. |
867871 | `ctrl+l` | Redraw the screen. |
868-| `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. |
872+| `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. |
869873 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
870874 | `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. |
871875 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -761,6 +761,10 @@ Opening `/model` refreshes the router and lists every loaded model under provide
761 761
762 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.762 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.
763 763
764+## Clipboard images
765+
766+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. Clipboard images larger than 32 MiB are ignored and use the normal text-paste fallback.
767+
764 ## Inline images768 ## Inline images
765 769
766 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:770 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:
@@ -865,7 +869,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
865 | `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. |869 | `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. |
866 | `ctrl+d` | Exit on empty input. |870 | `ctrl+d` | Exit on empty input. |
867 | `ctrl+l` | Redraw the screen. |871 | `ctrl+l` | Redraw the screen. |
868-| `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. |872+| `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. |
869 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |873 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
870 | `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. |874 | `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. |
871 | `@` | Open the file picker. Browse files and directories in the working directory. |875 | `@` | Open the file picker. Browse files and directories in the working directory. |
M packages/agent/modes/interactive.go +4 -11
@@ -2497,7 +2497,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
24972497 i.invalidate()
24982498 return false
24992499 case tui.KeyPasteClipboard:
2500- i.pasteClipboard()
2500+ i.pasteClipboard(ctx)
25012501 return false
25022502 case tui.KeyCtrlO:
25032503 i.toggleToolExpansion()
@@ -2789,21 +2789,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27892789 return false
27902790 }
27912791
2792-func (i *Interactive) pasteClipboard() {
2793- _, data, ok, err := tui.ReadClipboardImagePNG()
2794- if err != nil {
2795- i.mu.Lock()
2796- i.statusErr = "clipboard paste failed: " + err.Error()
2797- i.statusOK = ""
2798- i.mu.Unlock()
2799- return
2800- }
2792+func (i *Interactive) pasteClipboard(ctx context.Context) {
2793+ image, ok, _ := tui.ReadClipboardImage(ctx)
28012794 if ok {
28022795 i.mu.Lock()
28032796 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
28042797 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
28052798 Marker: marker,
2806- Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2799+ Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
28072800 })
28082801 i.ed.Insert(marker + " ")
28092802 i.statusOK = ""
@@ -2497,7 +2497,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2497 i.invalidate()2497 i.invalidate()
2498 return false2498 return false
2499 case tui.KeyPasteClipboard:2499 case tui.KeyPasteClipboard:
2500- i.pasteClipboard()2500+ i.pasteClipboard(ctx)
2501 return false2501 return false
2502 case tui.KeyCtrlO:2502 case tui.KeyCtrlO:
2503 i.toggleToolExpansion()2503 i.toggleToolExpansion()
@@ -2789,21 +2789,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2789 return false2789 return false
2790 }2790 }
2791 2791
2792-func (i *Interactive) pasteClipboard() {2792+func (i *Interactive) pasteClipboard(ctx context.Context) {
2793- _, data, ok, err := tui.ReadClipboardImagePNG()2793+ image, ok, _ := tui.ReadClipboardImage(ctx)
2794- if err != nil {
2795- i.mu.Lock()
2796- i.statusErr = "clipboard paste failed: " + err.Error()
2797- i.statusOK = ""
2798- i.mu.Unlock()
2799- return
2800- }
2801 if ok {2794 if ok {
2802 i.mu.Lock()2795 i.mu.Lock()
2803 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)2796 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2804 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{2797 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2805 Marker: marker,2798 Marker: marker,
2806- Image: provider.ImageBlock{MimeType: "image/png", Data: data},2799+ Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
2807 })2800 })
2808 i.ed.Insert(marker + " ")2801 i.ed.Insert(marker + " ")
2809 i.statusOK = ""2802 i.statusOK = ""
A packages/tui/clipboard.go +19 -0
@@ -0,0 +1,19 @@
1+package tui
2+
3+import (
4+ "errors"
5+ "time"
6+)
7+
8+const (
9+ clipboardImageReadTimeout = 5 * time.Second
10+ maxClipboardImageBytes = 32 << 20
11+)
12+
13+var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit")
14+
15+// ClipboardImage is an image read from the system clipboard.
16+type ClipboardImage struct {
17+ MimeType string
18+ Data []byte
19+}
@@ -0,0 +1,19 @@
1+package tui
2+
3+import (
4+ "errors"
5+ "time"
6+)
7+
8+const (
9+ clipboardImageReadTimeout = 5 * time.Second
10+ maxClipboardImageBytes = 32 << 20
11+)
12+
13+var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit")
14+
15+// ClipboardImage is an image read from the system clipboard.
16+type ClipboardImage struct {
17+ MimeType string
18+ Data []byte
19+}
M packages/tui/clipboard_darwin.go +62 -5
@@ -3,10 +3,12 @@
33 package tui
44
55 import (
6+ "context"
67 "crypto/rand"
78 "encoding/hex"
89 "fmt"
910 "image/png"
11+ "io"
1012 "os"
1113 "os/exec"
1214 "path/filepath"
@@ -47,7 +49,29 @@ on run argv
4749 end run
4850 `
4951
52+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
53+ if ctx == nil {
54+ ctx = context.Background()
55+ }
56+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
57+ defer cancel()
58+
59+ path, data, ok, err := readClipboardImagePNG(ctx)
60+ if err != nil {
61+ return ClipboardImage{}, false, err
62+ }
63+ if !ok {
64+ return ClipboardImage{}, false, nil
65+ }
66+ _ = removeClipboardImageFile(path)
67+ return ClipboardImage{MimeType: "image/png", Data: data}, true, nil
68+}
69+
5070 func ReadClipboardImagePNG() (string, []byte, bool, error) {
71+ return readClipboardImagePNG(context.Background())
72+}
73+
74+func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) {
5175 dir := clipboardImageDir()
5276 if err := os.MkdirAll(dir, 0o700); err != nil {
5377 return "", nil, false, err
@@ -56,7 +80,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
5680 rawPath := path + ".raw"
5781 defer os.Remove(rawPath)
5882
59- kind, err := writeClipboardImageData(rawPath)
83+ kind, err := writeClipboardImageData(ctx, rawPath)
6084 if err != nil {
6185 return "", nil, false, err
6286 }
@@ -79,19 +103,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
79103 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
80104 }
81105 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
106+ if err == errClipboardImageTooLarge {
107+ return "", nil, false, nil
108+ }
82109 return "", nil, false, err
83110 }
84111 }
85112
86- data, err := os.ReadFile(path)
113+ data, err := readClipboardImageFile(path)
114+ if err == errClipboardImageTooLarge {
115+ if err := removeClipboardImageFile(path); err != nil {
116+ return "", nil, false, err
117+ }
118+ return "", nil, false, nil
119+ }
87120 if err != nil {
88121 return "", nil, false, err
89122 }
90123 return path, data, true, nil
91124 }
92125
93-func writeClipboardImageData(path string) (string, error) {
94- cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
126+func writeClipboardImageData(ctx context.Context, path string) (string, error) {
127+ cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
95128 out, err := cmd.CombinedOutput()
96129 trimmed := strings.TrimSpace(string(out))
97130 if err != nil {
@@ -130,10 +163,34 @@ func clipboardImageKind(s string) (string, bool) {
130163 return "", false
131164 }
132165
166+func readClipboardImageFile(path string) ([]byte, error) {
167+ file, err := os.Open(path)
168+ if err != nil {
169+ return nil, err
170+ }
171+ defer file.Close()
172+
173+ data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1))
174+ if err != nil {
175+ return nil, err
176+ }
177+ if len(data) > maxClipboardImageBytes {
178+ return nil, errClipboardImageTooLarge
179+ }
180+ return data, nil
181+}
182+
183+func removeClipboardImageFile(path string) error {
184+ if err := os.Remove(path); err != nil {
185+ return fmt.Errorf("remove temporary clipboard image: %w", err)
186+ }
187+ return nil
188+}
189+
133190 func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
134191 switch strings.ToLower(filepath.Ext(srcPath)) {
135192 case ".png":
136- data, err := os.ReadFile(srcPath)
193+ data, err := readClipboardImageFile(srcPath)
137194 if err != nil {
138195 return err
139196 }
@@ -3,10 +3,12 @@
3 package tui3 package tui
4 4
5 import (5 import (
6+ "context"
6 "crypto/rand"7 "crypto/rand"
7 "encoding/hex"8 "encoding/hex"
8 "fmt"9 "fmt"
9 "image/png"10 "image/png"
11+ "io"
10 "os"12 "os"
11 "os/exec"13 "os/exec"
12 "path/filepath"14 "path/filepath"
@@ -47,7 +49,29 @@ on run argv
47 end run49 end run
48 `50 `
49 51
52+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
53+ if ctx == nil {
54+ ctx = context.Background()
55+ }
56+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
57+ defer cancel()
58+
59+ path, data, ok, err := readClipboardImagePNG(ctx)
60+ if err != nil {
61+ return ClipboardImage{}, false, err
62+ }
63+ if !ok {
64+ return ClipboardImage{}, false, nil
65+ }
66+ _ = removeClipboardImageFile(path)
67+ return ClipboardImage{MimeType: "image/png", Data: data}, true, nil
68+}
69+
50 func ReadClipboardImagePNG() (string, []byte, bool, error) {70 func ReadClipboardImagePNG() (string, []byte, bool, error) {
71+ return readClipboardImagePNG(context.Background())
72+}
73+
74+func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) {
51 dir := clipboardImageDir()75 dir := clipboardImageDir()
52 if err := os.MkdirAll(dir, 0o700); err != nil {76 if err := os.MkdirAll(dir, 0o700); err != nil {
53 return "", nil, false, err77 return "", nil, false, err
@@ -56,7 +80,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
56 rawPath := path + ".raw"80 rawPath := path + ".raw"
57 defer os.Remove(rawPath)81 defer os.Remove(rawPath)
58 82
59- kind, err := writeClipboardImageData(rawPath)83+ kind, err := writeClipboardImageData(ctx, rawPath)
60 if err != nil {84 if err != nil {
61 return "", nil, false, err85 return "", nil, false, err
62 }86 }
@@ -79,19 +103,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
79 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)103 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
80 }104 }
81 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {105 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
106+ if err == errClipboardImageTooLarge {
107+ return "", nil, false, nil
108+ }
82 return "", nil, false, err109 return "", nil, false, err
83 }110 }
84 }111 }
85 112
86- data, err := os.ReadFile(path)113+ data, err := readClipboardImageFile(path)
114+ if err == errClipboardImageTooLarge {
115+ if err := removeClipboardImageFile(path); err != nil {
116+ return "", nil, false, err
117+ }
118+ return "", nil, false, nil
119+ }
87 if err != nil {120 if err != nil {
88 return "", nil, false, err121 return "", nil, false, err
89 }122 }
90 return path, data, true, nil123 return path, data, true, nil
91 }124 }
92 125
93-func writeClipboardImageData(path string) (string, error) {126+func writeClipboardImageData(ctx context.Context, path string) (string, error) {
94- cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)127+ cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
95 out, err := cmd.CombinedOutput()128 out, err := cmd.CombinedOutput()
96 trimmed := strings.TrimSpace(string(out))129 trimmed := strings.TrimSpace(string(out))
97 if err != nil {130 if err != nil {
@@ -130,10 +163,34 @@ func clipboardImageKind(s string) (string, bool) {
130 return "", false163 return "", false
131 }164 }
132 165
166+func readClipboardImageFile(path string) ([]byte, error) {
167+ file, err := os.Open(path)
168+ if err != nil {
169+ return nil, err
170+ }
171+ defer file.Close()
172+
173+ data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1))
174+ if err != nil {
175+ return nil, err
176+ }
177+ if len(data) > maxClipboardImageBytes {
178+ return nil, errClipboardImageTooLarge
179+ }
180+ return data, nil
181+}
182+
183+func removeClipboardImageFile(path string) error {
184+ if err := os.Remove(path); err != nil {
185+ return fmt.Errorf("remove temporary clipboard image: %w", err)
186+ }
187+ return nil
188+}
189+
133 func copyClipboardImageFileToPNG(srcPath, dstPath string) error {190 func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
134 switch strings.ToLower(filepath.Ext(srcPath)) {191 switch strings.ToLower(filepath.Ext(srcPath)) {
135 case ".png":192 case ".png":
136- data, err := os.ReadFile(srcPath)193+ data, err := readClipboardImageFile(srcPath)
137 if err != nil {194 if err != nil {
138 return err195 return err
139 }196 }
A packages/tui/clipboard_darwin_test.go +42 -0
@@ -0,0 +1,42 @@
1+//go:build darwin
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestReadClipboardImageFileSizeLimit(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "clipboard.png")
14+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+ data, err := readClipboardImageFile(path)
18+ if err != nil || len(data) != maxClipboardImageBytes {
19+ t.Fatalf("exact-limit file result = (%d bytes, %v), want (%d bytes, nil)", len(data), err, maxClipboardImageBytes)
20+ }
21+
22+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ data, err = readClipboardImageFile(path)
26+ if err != errClipboardImageTooLarge || data != nil {
27+ t.Fatalf("over-limit file result = (%d bytes, %v), want (0 bytes, too-large)", len(data), err)
28+ }
29+}
30+
31+func TestRemoveClipboardImageFile(t *testing.T) {
32+ path := filepath.Join(t.TempDir(), "clipboard.png")
33+ if err := os.WriteFile(path, []byte("image"), 0o600); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := removeClipboardImageFile(path); err != nil {
37+ t.Fatalf("removeClipboardImageFile() error = %v", err)
38+ }
39+ if _, err := os.Stat(path); !os.IsNotExist(err) {
40+ t.Fatalf("clipboard image stat error = %v, want not-exist", err)
41+ }
42+}
@@ -0,0 +1,42 @@
1+//go:build darwin
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestReadClipboardImageFileSizeLimit(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "clipboard.png")
14+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+ data, err := readClipboardImageFile(path)
18+ if err != nil || len(data) != maxClipboardImageBytes {
19+ t.Fatalf("exact-limit file result = (%d bytes, %v), want (%d bytes, nil)", len(data), err, maxClipboardImageBytes)
20+ }
21+
22+ if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ data, err = readClipboardImageFile(path)
26+ if err != errClipboardImageTooLarge || data != nil {
27+ t.Fatalf("over-limit file result = (%d bytes, %v), want (0 bytes, too-large)", len(data), err)
28+ }
29+}
30+
31+func TestRemoveClipboardImageFile(t *testing.T) {
32+ path := filepath.Join(t.TempDir(), "clipboard.png")
33+ if err := os.WriteFile(path, []byte("image"), 0o600); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := removeClipboardImageFile(path); err != nil {
37+ t.Fatalf("removeClipboardImageFile() error = %v", err)
38+ }
39+ if _, err := os.Stat(path); !os.IsNotExist(err) {
40+ t.Fatalf("clipboard image stat error = %v, want not-exist", err)
41+ }
42+}
A packages/tui/clipboard_linux.go +132 -0
@@ -0,0 +1,132 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "context"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "strings"
11+)
12+
13+var supportedClipboardImageMIMEs = []string{
14+ "image/png",
15+ "image/jpeg",
16+ "image/gif",
17+ "image/webp",
18+}
19+
20+type clipboardImageCommand struct {
21+ name string
22+ args []string
23+}
24+
25+// ReadClipboardImage reads an image from the Wayland or X11 system clipboard.
26+// Clipboard helper failures are intentionally treated as an empty clipboard so
27+// that optional desktop integrations do not affect paste handling.
28+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
29+ if ctx == nil {
30+ ctx = context.Background()
31+ }
32+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
33+ defer cancel()
34+
35+ if os.Getenv("WAYLAND_DISPLAY") != "" {
36+ if image, ok := readClipboardImageWayland(ctx); ok {
37+ return image, true, nil
38+ }
39+ }
40+ if os.Getenv("DISPLAY") != "" {
41+ if image, ok := readClipboardImageX11(ctx); ok {
42+ return image, true, nil
43+ }
44+ }
45+ return ClipboardImage{}, false, nil
46+}
47+
48+// ReadClipboardImagePNG preserves the legacy Linux behavior. Linux image
49+// reads retain their clipboard MIME type through ReadClipboardImage instead.
50+func ReadClipboardImagePNG() (string, []byte, bool, error) {
51+ return "", nil, false, nil
52+}
53+
54+func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) {
55+ types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
56+ name: "wl-paste",
57+ args: []string{"--list-types"},
58+ })
59+ if !ok {
60+ return ClipboardImage{}, false
61+ }
62+
63+ available := make(map[string]bool)
64+ for _, line := range strings.Split(string(types), "\n") {
65+ mime := strings.ToLower(strings.TrimSpace(line))
66+ if isSupportedClipboardImageMIME(mime) {
67+ available[mime] = true
68+ }
69+ }
70+ for _, mime := range supportedClipboardImageMIMEs {
71+ if !available[mime] {
72+ continue
73+ }
74+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
75+ name: "wl-paste",
76+ args: []string{"--type", mime},
77+ })
78+ if ok {
79+ return ClipboardImage{MimeType: mime, Data: data}, true
80+ }
81+ }
82+ return ClipboardImage{}, false
83+}
84+
85+func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) {
86+ for _, mime := range supportedClipboardImageMIMEs {
87+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
88+ name: "xclip",
89+ args: []string{"-selection", "clipboard", "-out", "-target", mime},
90+ })
91+ if ok {
92+ return ClipboardImage{MimeType: mime, Data: data}, true
93+ }
94+ }
95+ return ClipboardImage{}, false
96+}
97+
98+func isSupportedClipboardImageMIME(mime string) bool {
99+ for _, supported := range supportedClipboardImageMIMEs {
100+ if mime == supported {
101+ return true
102+ }
103+ }
104+ return false
105+}
106+
107+func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) {
108+ path, err := exec.LookPath(command.name)
109+ if err != nil {
110+ return nil, false
111+ }
112+
113+ cmd := exec.CommandContext(ctx, path, command.args...)
114+ stdout, err := cmd.StdoutPipe()
115+ if err != nil {
116+ return nil, false
117+ }
118+ if err := cmd.Start(); err != nil {
119+ return nil, false
120+ }
121+
122+ data, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxClipboardImageBytes)+1))
123+ if readErr != nil || len(data) > maxClipboardImageBytes {
124+ _ = cmd.Process.Kill()
125+ _ = cmd.Wait()
126+ return nil, false
127+ }
128+ if err := cmd.Wait(); err != nil || len(data) == 0 {
129+ return nil, false
130+ }
131+ return data, true
132+}
@@ -0,0 +1,132 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "context"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "strings"
11+)
12+
13+var supportedClipboardImageMIMEs = []string{
14+ "image/png",
15+ "image/jpeg",
16+ "image/gif",
17+ "image/webp",
18+}
19+
20+type clipboardImageCommand struct {
21+ name string
22+ args []string
23+}
24+
25+// ReadClipboardImage reads an image from the Wayland or X11 system clipboard.
26+// Clipboard helper failures are intentionally treated as an empty clipboard so
27+// that optional desktop integrations do not affect paste handling.
28+func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
29+ if ctx == nil {
30+ ctx = context.Background()
31+ }
32+ ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
33+ defer cancel()
34+
35+ if os.Getenv("WAYLAND_DISPLAY") != "" {
36+ if image, ok := readClipboardImageWayland(ctx); ok {
37+ return image, true, nil
38+ }
39+ }
40+ if os.Getenv("DISPLAY") != "" {
41+ if image, ok := readClipboardImageX11(ctx); ok {
42+ return image, true, nil
43+ }
44+ }
45+ return ClipboardImage{}, false, nil
46+}
47+
48+// ReadClipboardImagePNG preserves the legacy Linux behavior. Linux image
49+// reads retain their clipboard MIME type through ReadClipboardImage instead.
50+func ReadClipboardImagePNG() (string, []byte, bool, error) {
51+ return "", nil, false, nil
52+}
53+
54+func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) {
55+ types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
56+ name: "wl-paste",
57+ args: []string{"--list-types"},
58+ })
59+ if !ok {
60+ return ClipboardImage{}, false
61+ }
62+
63+ available := make(map[string]bool)
64+ for _, line := range strings.Split(string(types), "\n") {
65+ mime := strings.ToLower(strings.TrimSpace(line))
66+ if isSupportedClipboardImageMIME(mime) {
67+ available[mime] = true
68+ }
69+ }
70+ for _, mime := range supportedClipboardImageMIMEs {
71+ if !available[mime] {
72+ continue
73+ }
74+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
75+ name: "wl-paste",
76+ args: []string{"--type", mime},
77+ })
78+ if ok {
79+ return ClipboardImage{MimeType: mime, Data: data}, true
80+ }
81+ }
82+ return ClipboardImage{}, false
83+}
84+
85+func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) {
86+ for _, mime := range supportedClipboardImageMIMEs {
87+ data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{
88+ name: "xclip",
89+ args: []string{"-selection", "clipboard", "-out", "-target", mime},
90+ })
91+ if ok {
92+ return ClipboardImage{MimeType: mime, Data: data}, true
93+ }
94+ }
95+ return ClipboardImage{}, false
96+}
97+
98+func isSupportedClipboardImageMIME(mime string) bool {
99+ for _, supported := range supportedClipboardImageMIMEs {
100+ if mime == supported {
101+ return true
102+ }
103+ }
104+ return false
105+}
106+
107+func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) {
108+ path, err := exec.LookPath(command.name)
109+ if err != nil {
110+ return nil, false
111+ }
112+
113+ cmd := exec.CommandContext(ctx, path, command.args...)
114+ stdout, err := cmd.StdoutPipe()
115+ if err != nil {
116+ return nil, false
117+ }
118+ if err := cmd.Start(); err != nil {
119+ return nil, false
120+ }
121+
122+ data, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxClipboardImageBytes)+1))
123+ if readErr != nil || len(data) > maxClipboardImageBytes {
124+ _ = cmd.Process.Kill()
125+ _ = cmd.Wait()
126+ return nil, false
127+ }
128+ if err := cmd.Wait(); err != nil || len(data) == 0 {
129+ return nil, false
130+ }
131+ return data, true
132+}
A packages/tui/clipboard_linux_test.go +146 -0
@@ -0,0 +1,146 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+)
12+
13+func TestReadClipboardImageWaylandUsesMIMEPriority(t *testing.T) {
14+ dir := t.TempDir()
15+ state := filepath.Join(dir, "listed")
16+ writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh
17+if [ "$1" = "--list-types" ]; then
18+ : > "$CLIPBOARD_TEST_STATE"
19+ printf 'image/jpeg\nimage/png\nimage/bmp\n'
20+ exit 0
21+fi
22+if [ "$1" = "--type" ]; then
23+ if [ ! -f "$CLIPBOARD_TEST_STATE" ]; then
24+ exit 1
25+ fi
26+ case "$2" in
27+ image/png) printf 'png-bytes' ;;
28+ image/jpeg) printf 'jpeg-bytes' ;;
29+ *) exit 1 ;;
30+ esac
31+ exit 0
32+fi
33+exit 1
34+`)
35+ t.Setenv("PATH", dir)
36+ t.Setenv("WAYLAND_DISPLAY", "wayland-test")
37+ t.Setenv("DISPLAY", "")
38+ t.Setenv("CLIPBOARD_TEST_STATE", state)
39+
40+ image, ok, err := ReadClipboardImage(context.Background())
41+ if err != nil {
42+ t.Fatalf("ReadClipboardImage() error = %v", err)
43+ }
44+ if !ok {
45+ t.Fatal("ReadClipboardImage() reported no image")
46+ }
47+ if image.MimeType != "image/png" {
48+ t.Fatalf("MIME type = %q, want image/png", image.MimeType)
49+ }
50+ if string(image.Data) != "png-bytes" {
51+ t.Fatalf("image data = %q, want png-bytes", image.Data)
52+ }
53+}
54+
55+func TestReadClipboardImageFallsBackToX11Helpers(t *testing.T) {
56+ dir := t.TempDir()
57+ writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh
58+if [ "$1" = "--list-types" ]; then
59+ printf 'text/plain\n'
60+fi
61+`)
62+ writeClipboardTestHelper(t, dir, "xclip", `#!/bin/sh
63+case "$*" in
64+ *"image/gif"*) printf 'gif-bytes' ;;
65+ *) exit 1 ;;
66+esac
67+`)
68+ t.Setenv("PATH", dir)
69+ t.Setenv("WAYLAND_DISPLAY", "wayland-test")
70+ t.Setenv("DISPLAY", ":test")
71+
72+ image, ok, err := ReadClipboardImage(context.Background())
73+ if err != nil {
74+ t.Fatalf("ReadClipboardImage() error = %v", err)
75+ }
76+ if !ok {
77+ t.Fatal("ReadClipboardImage() reported no image")
78+ }
79+ if image.MimeType != "image/gif" {
80+ t.Fatalf("MIME type = %q, want image/gif", image.MimeType)
81+ }
82+ if string(image.Data) != "gif-bytes" {
83+ t.Fatalf("image data = %q, want gif-bytes", image.Data)
84+ }
85+}
86+
87+func TestReadClipboardImageUnavailableIsNoImage(t *testing.T) {
88+ dir := t.TempDir()
89+ writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh
90+printf 'clipboard helper failure' >&2
91+exit 1
92+`)
93+ t.Setenv("PATH", dir)
94+ t.Setenv("WAYLAND_DISPLAY", "wayland-test")
95+ t.Setenv("DISPLAY", "")
96+
97+ image, ok, err := ReadClipboardImage(context.Background())
98+ if err != nil {
99+ t.Fatalf("ReadClipboardImage() error = %v, want nil", err)
100+ }
101+ if ok {
102+ t.Fatalf("ReadClipboardImage() returned image %#v", image)
103+ }
104+ if image.MimeType != "" || len(image.Data) != 0 {
105+ t.Fatalf("empty image = %#v", image)
106+ }
107+
108+ path, data, ok, err := ReadClipboardImagePNG()
109+ if err != nil || ok || path != "" || data != nil {
110+ t.Fatalf("ReadClipboardImagePNG() = (%q, %v, %v, %v), want empty result", path, data, ok, err)
111+ }
112+}
113+
114+func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) {
115+ dir := t.TempDir()
116+ payload := filepath.Join(dir, "payload")
117+ writeClipboardTestHelper(t, dir, "clipboard-test-helper", `#!/bin/sh
118+exec /bin/cat "$CLIPBOARD_TEST_PAYLOAD"
119+`)
120+ t.Setenv("PATH", dir)
121+ t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
122+
123+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
124+ t.Fatal(err)
125+ }
126+ data, ok := runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
127+ if !ok || len(data) != maxClipboardImageBytes {
128+ t.Fatalf("exact-limit command result = (%d bytes, %v), want (%d bytes, true)", len(data), ok, maxClipboardImageBytes)
129+ }
130+
131+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+ data, ok = runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
135+ if ok || data != nil {
136+ t.Fatalf("over-limit command result = (%d bytes, %v), want (0 bytes, false)", len(data), ok)
137+ }
138+}
139+
140+func writeClipboardTestHelper(t *testing.T, dir, name, script string) {
141+ t.Helper()
142+ path := filepath.Join(dir, name)
143+ if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
144+ t.Fatal(err)
145+ }
146+}
@@ -0,0 +1,146 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+)
12+
13+func TestReadClipboardImageWaylandUsesMIMEPriority(t *testing.T) {
14+ dir := t.TempDir()
15+ state := filepath.Join(dir, "listed")
16+ writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh
17+if [ "$1" = "--list-types" ]; then
18+ : > "$CLIPBOARD_TEST_STATE"
19+ printf 'image/jpeg\nimage/png\nimage/bmp\n'
20+ exit 0
21+fi
22+if [ "$1" = "--type" ]; then
23+ if [ ! -f "$CLIPBOARD_TEST_STATE" ]; then
24+ exit 1
25+ fi
26+ case "$2" in
27+ image/png) printf 'png-bytes' ;;
28+ image/jpeg) printf 'jpeg-bytes' ;;
29+ *) exit 1 ;;
30+ esac
31+ exit 0
32+fi
33+exit 1
34+`)
35+ t.Setenv("PATH", dir)
36+ t.Setenv("WAYLAND_DISPLAY", "wayland-test")
37+ t.Setenv("DISPLAY", "")
38+ t.Setenv("CLIPBOARD_TEST_STATE", state)
39+
40+ image, ok, err := ReadClipboardImage(context.Background())
41+ if err != nil {
42+ t.Fatalf("ReadClipboardImage() error = %v", err)
43+ }
44+ if !ok {
45+ t.Fatal("ReadClipboardImage() reported no image")
46+ }
47+ if image.MimeType != "image/png" {
48+ t.Fatalf("MIME type = %q, want image/png", image.MimeType)
49+ }
50+ if string(image.Data) != "png-bytes" {
51+ t.Fatalf("image data = %q, want png-bytes", image.Data)
52+ }
53+}
54+
55+func TestReadClipboardImageFallsBackToX11Helpers(t *testing.T) {
56+ dir := t.TempDir()
57+ writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh
58+if [ "$1" = "--list-types" ]; then
59+ printf 'text/plain\n'
60+fi
61+`)
62+ writeClipboardTestHelper(t, dir, "xclip", `#!/bin/sh
63+case "$*" in
64+ *"image/gif"*) printf 'gif-bytes' ;;
65+ *) exit 1 ;;
66+esac
67+`)
68+ t.Setenv("PATH", dir)
69+ t.Setenv("WAYLAND_DISPLAY", "wayland-test")
70+ t.Setenv("DISPLAY", ":test")
71+
72+ image, ok, err := ReadClipboardImage(context.Background())
73+ if err != nil {
74+ t.Fatalf("ReadClipboardImage() error = %v", err)
75+ }
76+ if !ok {
77+ t.Fatal("ReadClipboardImage() reported no image")
78+ }
79+ if image.MimeType != "image/gif" {
80+ t.Fatalf("MIME type = %q, want image/gif", image.MimeType)
81+ }
82+ if string(image.Data) != "gif-bytes" {
83+ t.Fatalf("image data = %q, want gif-bytes", image.Data)
84+ }
85+}
86+
87+func TestReadClipboardImageUnavailableIsNoImage(t *testing.T) {
88+ dir := t.TempDir()
89+ writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh
90+printf 'clipboard helper failure' >&2
91+exit 1
92+`)
93+ t.Setenv("PATH", dir)
94+ t.Setenv("WAYLAND_DISPLAY", "wayland-test")
95+ t.Setenv("DISPLAY", "")
96+
97+ image, ok, err := ReadClipboardImage(context.Background())
98+ if err != nil {
99+ t.Fatalf("ReadClipboardImage() error = %v, want nil", err)
100+ }
101+ if ok {
102+ t.Fatalf("ReadClipboardImage() returned image %#v", image)
103+ }
104+ if image.MimeType != "" || len(image.Data) != 0 {
105+ t.Fatalf("empty image = %#v", image)
106+ }
107+
108+ path, data, ok, err := ReadClipboardImagePNG()
109+ if err != nil || ok || path != "" || data != nil {
110+ t.Fatalf("ReadClipboardImagePNG() = (%q, %v, %v, %v), want empty result", path, data, ok, err)
111+ }
112+}
113+
114+func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) {
115+ dir := t.TempDir()
116+ payload := filepath.Join(dir, "payload")
117+ writeClipboardTestHelper(t, dir, "clipboard-test-helper", `#!/bin/sh
118+exec /bin/cat "$CLIPBOARD_TEST_PAYLOAD"
119+`)
120+ t.Setenv("PATH", dir)
121+ t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
122+
123+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil {
124+ t.Fatal(err)
125+ }
126+ data, ok := runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
127+ if !ok || len(data) != maxClipboardImageBytes {
128+ t.Fatalf("exact-limit command result = (%d bytes, %v), want (%d bytes, true)", len(data), ok, maxClipboardImageBytes)
129+ }
130+
131+ if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+ data, ok = runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"})
135+ if ok || data != nil {
136+ t.Fatalf("over-limit command result = (%d bytes, %v), want (0 bytes, false)", len(data), ok)
137+ }
138+}
139+
140+func writeClipboardTestHelper(t *testing.T, dir, name, script string) {
141+ t.Helper()
142+ path := filepath.Join(dir, name)
143+ if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
144+ t.Fatal(err)
145+ }
146+}
M packages/tui/clipboard_stub.go +7 -1
@@ -1,7 +1,13 @@
1-//go:build !darwin
1+//go:build !darwin && !linux
22
33 package tui
44
5+import "context"
6+
7+func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) {
8+ return ClipboardImage{}, false, nil
9+}
10+
511 func ReadClipboardImagePNG() (string, []byte, bool, error) {
612 return "", nil, false, nil
713 }
@@ -1,7 +1,13 @@
1-//go:build !darwin1+//go:build !darwin && !linux
2 2
3 package tui3 package tui
4 4
5+import "context"
6+
7+func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) {
8+ return ClipboardImage{}, false, nil
9+}
10+
5 func ReadClipboardImagePNG() (string, []byte, bool, error) {11 func ReadClipboardImagePNG() (string, []byte, bool, error) {
6 return "", nil, false, nil12 return "", nil, false, nil
7 }13 }

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