detached 3346322

feat(tui): paste images from Linux clipboards

commit
3346322247ced76374f8d386de5de0e188006b2e
author
brice · · 2026-08-04 09:21 UTC
parent
0a849d0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
759759
760760 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.
761761
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+
762766 ## Inline images
763767
764768 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`,
863867 | `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. |
864868 | `ctrl+d` | Exit on empty input. |
865869 | `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. |
867871 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
868872 | `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. |
869873 | `@` | 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 images766 ## 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) {
27672767 }
27682768
27692769 func (i *Interactive) pasteClipboard() {
2770- _, data, ok, err := tui.ReadClipboardImagePNG()
2770+ image, ok, err := tui.ReadClipboardImage()
27712771 if err != nil {
27722772 i.mu.Lock()
27732773 i.statusErr = "clipboard paste failed: " + err.Error()
@@ -2780,7 +2780,7 @@ func (i *Interactive) pasteClipboard() {
27802780 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
27812781 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
27822782 Marker: marker,
2783- Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2783+ Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
27842784 })
27852785 i.ed.Insert(marker + " ")
27862786 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
4747 end run
4848 `
4949
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+
5061 func ReadClipboardImagePNG() (string, []byte, bool, error) {
5162 dir := clipboardImageDir()
5263 if err := os.MkdirAll(dir, 0o700); err != nil {
@@ -47,6 +47,17 @@ on run argv
47 end run47 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
22
33 package tui
44
5+func ReadClipboardImage() (ClipboardImage, bool, error) {
6+ return ClipboardImage{}, false, nil
7+}
8+
59 func ReadClipboardImagePNG() (string, []byte, bool, error) {
610 return "", nil, false, nil
711 }
@@ -1,7 +1,11 @@
1-//go:build !darwin1+//go:build !darwin && !linux
2 2
3 package tui3 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, nil10 return "", nil, false, nil
7 }11 }

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 477

↑↓ moveenter openesc close