detached 1f663cb

fix: use osascript for macos clipboard image paste

commit
1f663cb867439ef84b2f5d9f81eaa8af38a0159e
author
mi-skam · · 2026-06-21 20:27 UTC
committer
mi-skam · · 2026-06-21 20:31 UTC
parent
4a6d691
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
7 files · +229 -134
M README.md +1 -1
@@ -576,7 +576,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
576576 | `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. |
577577 | `ctrl+d` | Exit on empty input. |
578578 | `ctrl+l` | Redraw the screen. |
579-| `ctrl+v` | Paste from the system clipboard through zot. Text is inserted normally; images are saved as temporary PNGs and attached to the next prompt. |
579+| `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. |
580580 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
581581 | `@` | Open the file picker. Browse files and directories in the working directory. |
582582
@@ -576,7 +576,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
576 | `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. |576 | `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. |
577 | `ctrl+d` | Exit on empty input. |577 | `ctrl+d` | Exit on empty input. |
578 | `ctrl+l` | Redraw the screen. |578 | `ctrl+l` | Redraw the screen. |
579-| `ctrl+v` | Paste from the system clipboard through zot. Text is inserted normally; images are saved as temporary PNGs and attached to the next prompt. |579+| `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. |
580 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |580 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
581 | `@` | Open the file picker. Browse files and directories in the working directory. |581 | `@` | Open the file picker. Browse files and directories in the working directory. |
582 582
A packages/agent/modes/clipboard_images.go +26 -0
@@ -0,0 +1,26 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+type clipboardImageAttachment struct {
10+ Marker string
11+ Image provider.ImageBlock
12+}
13+
14+func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
15+ out := text
16+ images := make([]provider.ImageBlock, 0, len(pending))
17+ for _, item := range pending {
18+ if item.Marker == "" || !strings.Contains(out, item.Marker) {
19+ continue
20+ }
21+ out = strings.ReplaceAll(out, item.Marker, " ")
22+ images = append(images, item.Image)
23+ }
24+ out = strings.Join(strings.Fields(out), " ")
25+ return out, images
26+}
@@ -0,0 +1,26 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+type clipboardImageAttachment struct {
10+ Marker string
11+ Image provider.ImageBlock
12+}
13+
14+func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
15+ out := text
16+ images := make([]provider.ImageBlock, 0, len(pending))
17+ for _, item := range pending {
18+ if item.Marker == "" || !strings.Contains(out, item.Marker) {
19+ continue
20+ }
21+ out = strings.ReplaceAll(out, item.Marker, " ")
22+ images = append(images, item.Image)
23+ }
24+ out = strings.Join(strings.Fields(out), " ")
25+ return out, images
26+}
A packages/agent/modes/clipboard_images_test.go +92 -0
@@ -0,0 +1,92 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func testClipboardImage(marker string, data string) clipboardImageAttachment {
11+ return clipboardImageAttachment{
12+ Marker: marker,
13+ Image: provider.ImageBlock{MimeType: "image/png", Data: []byte(data)},
14+ }
15+}
16+
17+func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {
18+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
19+
20+ text, images := preparePromptWithClipboardImages("describe this [clipboard image #1] please", pending)
21+
22+ if text != "describe this please" {
23+ t.Fatalf("text = %q, want %q", text, "describe this please")
24+ }
25+ if len(images) != 1 {
26+ t.Fatalf("len(images) = %d, want 1", len(images))
27+ }
28+ if string(images[0].Data) != "png-1" {
29+ t.Fatalf("image data = %q, want png-1", string(images[0].Data))
30+ }
31+}
32+
33+func TestPreparePromptWithClipboardImagesAllowsImageOnlyPrompt(t *testing.T) {
34+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
35+
36+ text, images := preparePromptWithClipboardImages("[clipboard image #1]", pending)
37+
38+ if text != "" {
39+ t.Fatalf("text = %q, want empty", text)
40+ }
41+ if len(images) != 1 {
42+ t.Fatalf("len(images) = %d, want 1", len(images))
43+ }
44+}
45+
46+func TestPreparePromptWithClipboardImagesIgnoresDeletedMarker(t *testing.T) {
47+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
48+
49+ text, images := preparePromptWithClipboardImages("describe this from memory", pending)
50+
51+ if text != "describe this from memory" {
52+ t.Fatalf("text = %q, want unchanged", text)
53+ }
54+ if len(images) != 0 {
55+ t.Fatalf("len(images) = %d, want 0", len(images))
56+ }
57+}
58+
59+func TestPreparePromptWithClipboardImagesHandlesMultipleImagesInPasteOrder(t *testing.T) {
60+ pending := []clipboardImageAttachment{
61+ testClipboardImage("[clipboard image #1]", "png-1"),
62+ testClipboardImage("[clipboard image #2]", "png-2"),
63+ }
64+
65+ text, images := preparePromptWithClipboardImages("compare [clipboard image #2] with [clipboard image #1]", pending)
66+
67+ if text != "compare with" {
68+ t.Fatalf("text = %q, want %q", text, "compare with")
69+ }
70+ if len(images) != 2 {
71+ t.Fatalf("len(images) = %d, want 2", len(images))
72+ }
73+ if string(images[0].Data) != "png-1" || string(images[1].Data) != "png-2" {
74+ t.Fatalf("images not attached in paste order: %q, %q", string(images[0].Data), string(images[1].Data))
75+ }
76+}
77+
78+func TestPreparePromptWithClipboardImagesDuplicateMarkerAttachesOnce(t *testing.T) {
79+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
80+
81+ text, images := preparePromptWithClipboardImages("[clipboard image #1] and again [clipboard image #1]", pending)
82+
83+ if strings.Contains(text, "[clipboard image #1]") {
84+ t.Fatalf("text still contains marker: %q", text)
85+ }
86+ if text != "and again" {
87+ t.Fatalf("text = %q, want %q", text, "and again")
88+ }
89+ if len(images) != 1 {
90+ t.Fatalf("len(images) = %d, want 1", len(images))
91+ }
92+}
@@ -0,0 +1,92 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func testClipboardImage(marker string, data string) clipboardImageAttachment {
11+ return clipboardImageAttachment{
12+ Marker: marker,
13+ Image: provider.ImageBlock{MimeType: "image/png", Data: []byte(data)},
14+ }
15+}
16+
17+func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {
18+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
19+
20+ text, images := preparePromptWithClipboardImages("describe this [clipboard image #1] please", pending)
21+
22+ if text != "describe this please" {
23+ t.Fatalf("text = %q, want %q", text, "describe this please")
24+ }
25+ if len(images) != 1 {
26+ t.Fatalf("len(images) = %d, want 1", len(images))
27+ }
28+ if string(images[0].Data) != "png-1" {
29+ t.Fatalf("image data = %q, want png-1", string(images[0].Data))
30+ }
31+}
32+
33+func TestPreparePromptWithClipboardImagesAllowsImageOnlyPrompt(t *testing.T) {
34+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
35+
36+ text, images := preparePromptWithClipboardImages("[clipboard image #1]", pending)
37+
38+ if text != "" {
39+ t.Fatalf("text = %q, want empty", text)
40+ }
41+ if len(images) != 1 {
42+ t.Fatalf("len(images) = %d, want 1", len(images))
43+ }
44+}
45+
46+func TestPreparePromptWithClipboardImagesIgnoresDeletedMarker(t *testing.T) {
47+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
48+
49+ text, images := preparePromptWithClipboardImages("describe this from memory", pending)
50+
51+ if text != "describe this from memory" {
52+ t.Fatalf("text = %q, want unchanged", text)
53+ }
54+ if len(images) != 0 {
55+ t.Fatalf("len(images) = %d, want 0", len(images))
56+ }
57+}
58+
59+func TestPreparePromptWithClipboardImagesHandlesMultipleImagesInPasteOrder(t *testing.T) {
60+ pending := []clipboardImageAttachment{
61+ testClipboardImage("[clipboard image #1]", "png-1"),
62+ testClipboardImage("[clipboard image #2]", "png-2"),
63+ }
64+
65+ text, images := preparePromptWithClipboardImages("compare [clipboard image #2] with [clipboard image #1]", pending)
66+
67+ if text != "compare with" {
68+ t.Fatalf("text = %q, want %q", text, "compare with")
69+ }
70+ if len(images) != 2 {
71+ t.Fatalf("len(images) = %d, want 2", len(images))
72+ }
73+ if string(images[0].Data) != "png-1" || string(images[1].Data) != "png-2" {
74+ t.Fatalf("images not attached in paste order: %q, %q", string(images[0].Data), string(images[1].Data))
75+ }
76+}
77+
78+func TestPreparePromptWithClipboardImagesDuplicateMarkerAttachesOnce(t *testing.T) {
79+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
80+
81+ text, images := preparePromptWithClipboardImages("[clipboard image #1] and again [clipboard image #1]", pending)
82+
83+ if strings.Contains(text, "[clipboard image #1]") {
84+ t.Fatalf("text still contains marker: %q", text)
85+ }
86+ if text != "and again" {
87+ t.Fatalf("text = %q, want %q", text, "and again")
88+ }
89+ if len(images) != 1 {
90+ t.Fatalf("len(images) = %d, want 1", len(images))
91+ }
92+}
M packages/agent/modes/interactive.go +13 -21
@@ -425,7 +425,7 @@ type Interactive struct {
425425 pendingRescuePrompt string
426426 pendingRescueImages []provider.ImageBlock
427427
428- clipboardImages []provider.ImageBlock
428+ clipboardImages []clipboardImageAttachment
429429 }
430430
431431 // welcomeVersionDuration is how long the welcome banner shows the
@@ -2231,10 +2231,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22312231 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
22322232 // Expand [file:name] and [dir:name/] chips to full paths.
22332233 text = expandFileChips(text, i.cfg.CWD)
2234- if text == "" {
2234+ text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2235+ if text == "" && len(images) == 0 {
22352236 return false
22362237 }
2237- images := append([]provider.ImageBlock(nil), i.clipboardImages...)
22382238 i.clipboardImages = nil
22392239 i.ed.Clear()
22402240 i.inputHistoryIndex = -1
@@ -2325,7 +2325,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23252325 }
23262326
23272327 func (i *Interactive) pasteClipboard() {
2328- path, data, ok, err := tui.ReadClipboardImagePNG()
2328+ _, data, ok, err := tui.ReadClipboardImagePNG()
23292329 if err != nil {
23302330 i.mu.Lock()
23312331 i.statusErr = "clipboard paste failed: " + err.Error()
@@ -2335,29 +2335,21 @@ func (i *Interactive) pasteClipboard() {
23352335 }
23362336 if ok {
23372337 i.mu.Lock()
2338- i.clipboardImages = append(i.clipboardImages, provider.ImageBlock{MimeType: "image/png", Data: data})
2339- i.ed.Insert("[image:" + path + "]")
2340- i.statusOK = "pasted clipboard image to " + path
2338+ marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2339+ i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2340+ Marker: marker,
2341+ Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2342+ })
2343+ i.ed.Insert(marker + " ")
2344+ i.statusOK = ""
23412345 i.statusErr = ""
23422346 i.mu.Unlock()
23432347 return
23442348 }
2345- text, textOK, err := tui.ReadClipboardText()
23462349 i.mu.Lock()
2347- defer i.mu.Unlock()
2348- if err != nil {
2349- i.statusErr = "clipboard paste failed: " + err.Error()
2350- i.statusOK = ""
2351- return
2352- }
2353- if !textOK || text == "" {
2354- i.statusErr = "clipboard does not contain text or an image"
2355- i.statusOK = ""
2356- return
2357- }
2358- i.ed.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: text})
2350+ i.statusErr = "clipboard does not contain an image"
23592351 i.statusOK = ""
2360- i.statusErr = ""
2352+ i.mu.Unlock()
23612353 }
23622354
23632355 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
@@ -425,7 +425,7 @@ type Interactive struct {
425 pendingRescuePrompt string425 pendingRescuePrompt string
426 pendingRescueImages []provider.ImageBlock426 pendingRescueImages []provider.ImageBlock
427 427
428- clipboardImages []provider.ImageBlock428+ clipboardImages []clipboardImageAttachment
429 }429 }
430 430
431 // welcomeVersionDuration is how long the welcome banner shows the431 // welcomeVersionDuration is how long the welcome banner shows the
@@ -2231,10 +2231,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2231 text := strings.TrimRight(i.ed.SubmitValue(), "\n")2231 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2232 // Expand [file:name] and [dir:name/] chips to full paths.2232 // Expand [file:name] and [dir:name/] chips to full paths.
2233 text = expandFileChips(text, i.cfg.CWD)2233 text = expandFileChips(text, i.cfg.CWD)
2234- if text == "" {2234+ text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2235+ if text == "" && len(images) == 0 {
2235 return false2236 return false
2236 }2237 }
2237- images := append([]provider.ImageBlock(nil), i.clipboardImages...)
2238 i.clipboardImages = nil2238 i.clipboardImages = nil
2239 i.ed.Clear()2239 i.ed.Clear()
2240 i.inputHistoryIndex = -12240 i.inputHistoryIndex = -1
@@ -2325,7 +2325,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2325 }2325 }
2326 2326
2327 func (i *Interactive) pasteClipboard() {2327 func (i *Interactive) pasteClipboard() {
2328- path, data, ok, err := tui.ReadClipboardImagePNG()2328+ _, data, ok, err := tui.ReadClipboardImagePNG()
2329 if err != nil {2329 if err != nil {
2330 i.mu.Lock()2330 i.mu.Lock()
2331 i.statusErr = "clipboard paste failed: " + err.Error()2331 i.statusErr = "clipboard paste failed: " + err.Error()
@@ -2335,29 +2335,21 @@ func (i *Interactive) pasteClipboard() {
2335 }2335 }
2336 if ok {2336 if ok {
2337 i.mu.Lock()2337 i.mu.Lock()
2338- i.clipboardImages = append(i.clipboardImages, provider.ImageBlock{MimeType: "image/png", Data: data})2338+ marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2339- i.ed.Insert("[image:" + path + "]")2339+ i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2340- i.statusOK = "pasted clipboard image to " + path2340+ Marker: marker,
2341+ Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2342+ })
2343+ i.ed.Insert(marker + " ")
2344+ i.statusOK = ""
2341 i.statusErr = ""2345 i.statusErr = ""
2342 i.mu.Unlock()2346 i.mu.Unlock()
2343 return2347 return
2344 }2348 }
2345- text, textOK, err := tui.ReadClipboardText()
2346 i.mu.Lock()2349 i.mu.Lock()
2347- defer i.mu.Unlock()2350+ i.statusErr = "clipboard does not contain an image"
2348- if err != nil {
2349- i.statusErr = "clipboard paste failed: " + err.Error()
2350- i.statusOK = ""
2351- return
2352- }
2353- if !textOK || text == "" {
2354- i.statusErr = "clipboard does not contain text or an image"
2355- i.statusOK = ""
2356- return
2357- }
2358- i.ed.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: text})
2359 i.statusOK = ""2351 i.statusOK = ""
2360- i.statusErr = ""2352+ i.mu.Unlock()
2361 }2353 }
2362 2354
2363 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {2355 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
M packages/tui/clipboard_darwin.go +96 -71
@@ -1,97 +1,82 @@
1-//go:build darwin && cgo
1+//go:build darwin
22
33 package tui
44
5-/*
6-#cgo CFLAGS: -x objective-c
7-#cgo LDFLAGS: -framework AppKit
8-#import <AppKit/AppKit.h>
9-#include <stdlib.h>
10-#include <string.h>
11-
12-static char* zot_strdup(const char *s) {
13- if (s == NULL) return NULL;
14- char *out = malloc(strlen(s) + 1);
15- if (out != NULL) strcpy(out, s);
16- return out;
17-}
18-
19-static int ZotWriteClipboardPNG(const char *path, char **err) {
20- @autoreleasepool {
21- NSPasteboard *pb = [NSPasteboard generalPasteboard];
22- NSArray *classes = @[[NSImage class]];
23- NSDictionary *options = @{};
24- NSArray *objects = [pb readObjectsForClasses:classes options:options];
25- NSImage *image = nil;
26- if ([objects count] > 0 && [[objects objectAtIndex:0] isKindOfClass:[NSImage class]]) {
27- image = (NSImage *)[objects objectAtIndex:0];
28- }
29- if (image == nil) {
30- NSData *tiff = [pb dataForType:NSPasteboardTypeTIFF];
31- if (tiff != nil) image = [[NSImage alloc] initWithData:tiff];
32- }
33- if (image == nil) return 1;
34-
35- NSData *tiff = [image TIFFRepresentation];
36- if (tiff == nil) {
37- if (err != NULL) *err = zot_strdup("clipboard image has no TIFF representation");
38- return 2;
39- }
40- NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff];
41- if (rep == nil) {
42- if (err != NULL) *err = zot_strdup("clipboard image could not be converted to a bitmap");
43- return 2;
44- }
45- NSData *png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
46- if (png == nil) {
47- if (err != NULL) *err = zot_strdup("clipboard image could not be encoded as PNG");
48- return 2;
49- }
50- NSString *dst = [NSString stringWithUTF8String:path];
51- NSError *writeErr = nil;
52- if (![png writeToFile:dst options:NSDataWritingAtomic error:&writeErr]) {
53- if (err != NULL) *err = zot_strdup([[writeErr localizedDescription] UTF8String]);
54- return 2;
55- }
56- return 0;
57- }
58-}
59-*/
60-import "C"
61-
625 import (
636 "crypto/rand"
647 "encoding/hex"
658 "fmt"
9+ "image/png"
6610 "os"
11+ "os/exec"
6712 "path/filepath"
13+ "strings"
6814 "time"
69- "unsafe"
15+
16+ "golang.org/x/image/tiff"
7017 )
7118
19+const readClipboardImageScript = `
20+on run argv
21+ set outPath to item 1 of argv
22+ try
23+ set imgData to the clipboard as «class PNGf»
24+ set imgKind to "png"
25+ on error
26+ try
27+ set imgData to the clipboard as «class TIFF»
28+ set imgKind to "tiff"
29+ on error
30+ return "NO_IMAGE"
31+ end try
32+ end try
33+
34+ set outFile to POSIX file outPath
35+ set fileRef to open for access outFile with write permission
36+ try
37+ set eof of fileRef to 0
38+ write imgData to fileRef
39+ close access fileRef
40+ on error errMsg number errNum
41+ try
42+ close access fileRef
43+ end try
44+ error errMsg number errNum
45+ end try
46+ return imgKind
47+end run
48+`
49+
7250 func ReadClipboardImagePNG() (string, []byte, bool, error) {
7351 dir := clipboardImageDir()
7452 if err := os.MkdirAll(dir, 0o700); err != nil {
7553 return "", nil, false, err
7654 }
7755 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
78- cpath := C.CString(path)
79- defer C.free(unsafe.Pointer(cpath))
80- var cerr *C.char
81- code := C.ZotWriteClipboardPNG(cpath, &cerr)
82- if cerr != nil {
83- defer C.free(unsafe.Pointer(cerr))
56+ rawPath := path + ".raw"
57+ defer os.Remove(rawPath)
58+
59+ kind, err := writeClipboardImageData(rawPath)
60+ if err != nil {
61+ return "", nil, false, err
8462 }
85- if code == 1 {
63+ if kind == "" {
8664 return "", nil, false, nil
8765 }
88- if code != 0 {
89- msg := "could not read image from clipboard"
90- if cerr != nil {
91- msg = C.GoString(cerr)
66+
67+ switch kind {
68+ case "png":
69+ if err := os.Rename(rawPath, path); err != nil {
70+ return "", nil, false, err
71+ }
72+ case "tiff":
73+ if err := convertTIFFFileToPNG(rawPath, path); err != nil {
74+ return "", nil, false, err
9275 }
93- return "", nil, false, fmt.Errorf("%s", msg)
76+ default:
77+ return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
9478 }
79+
9580 data, err := os.ReadFile(path)
9681 if err != nil {
9782 return "", nil, false, err
@@ -99,6 +84,46 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
9984 return path, data, true, nil
10085 }
10186
87+func writeClipboardImageData(path string) (string, error) {
88+ cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
89+ out, err := cmd.CombinedOutput()
90+ trimmed := strings.TrimSpace(string(out))
91+ if err != nil {
92+ if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
93+ return "", nil
94+ }
95+ if trimmed == "" {
96+ return "", fmt.Errorf("osascript failed: %w", err)
97+ }
98+ return "", fmt.Errorf("osascript failed: %s", trimmed)
99+ }
100+ if trimmed == "NO_IMAGE" {
101+ return "", nil
102+ }
103+ return trimmed, nil
104+}
105+
106+func convertTIFFFileToPNG(srcPath, dstPath string) error {
107+ in, err := os.Open(srcPath)
108+ if err != nil {
109+ return err
110+ }
111+ defer in.Close()
112+
113+ img, err := tiff.Decode(in)
114+ if err != nil {
115+ return err
116+ }
117+
118+ out, err := os.Create(dstPath)
119+ if err != nil {
120+ return err
121+ }
122+ defer out.Close()
123+
124+ return png.Encode(out, img)
125+}
126+
102127 func clipboardImageDir() string {
103128 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
104129 return filepath.Join("/tmp", "zot-clipboard-images")
@@ -1,97 +1,82 @@
1-//go:build darwin && cgo1+//go:build darwin
2 2
3 package tui3 package tui
4 4
5-/*
6-#cgo CFLAGS: -x objective-c
7-#cgo LDFLAGS: -framework AppKit
8-#import <AppKit/AppKit.h>
9-#include <stdlib.h>
10-#include <string.h>
11-
12-static char* zot_strdup(const char *s) {
13- if (s == NULL) return NULL;
14- char *out = malloc(strlen(s) + 1);
15- if (out != NULL) strcpy(out, s);
16- return out;
17-}
18-
19-static int ZotWriteClipboardPNG(const char *path, char **err) {
20- @autoreleasepool {
21- NSPasteboard *pb = [NSPasteboard generalPasteboard];
22- NSArray *classes = @[[NSImage class]];
23- NSDictionary *options = @{};
24- NSArray *objects = [pb readObjectsForClasses:classes options:options];
25- NSImage *image = nil;
26- if ([objects count] > 0 && [[objects objectAtIndex:0] isKindOfClass:[NSImage class]]) {
27- image = (NSImage *)[objects objectAtIndex:0];
28- }
29- if (image == nil) {
30- NSData *tiff = [pb dataForType:NSPasteboardTypeTIFF];
31- if (tiff != nil) image = [[NSImage alloc] initWithData:tiff];
32- }
33- if (image == nil) return 1;
34-
35- NSData *tiff = [image TIFFRepresentation];
36- if (tiff == nil) {
37- if (err != NULL) *err = zot_strdup("clipboard image has no TIFF representation");
38- return 2;
39- }
40- NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff];
41- if (rep == nil) {
42- if (err != NULL) *err = zot_strdup("clipboard image could not be converted to a bitmap");
43- return 2;
44- }
45- NSData *png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
46- if (png == nil) {
47- if (err != NULL) *err = zot_strdup("clipboard image could not be encoded as PNG");
48- return 2;
49- }
50- NSString *dst = [NSString stringWithUTF8String:path];
51- NSError *writeErr = nil;
52- if (![png writeToFile:dst options:NSDataWritingAtomic error:&writeErr]) {
53- if (err != NULL) *err = zot_strdup([[writeErr localizedDescription] UTF8String]);
54- return 2;
55- }
56- return 0;
57- }
58-}
59-*/
60-import "C"
61-
62 import (5 import (
63 "crypto/rand"6 "crypto/rand"
64 "encoding/hex"7 "encoding/hex"
65 "fmt"8 "fmt"
9+ "image/png"
66 "os"10 "os"
11+ "os/exec"
67 "path/filepath"12 "path/filepath"
13+ "strings"
68 "time"14 "time"
69- "unsafe"15+
16+ "golang.org/x/image/tiff"
70 )17 )
71 18
19+const readClipboardImageScript = `
20+on run argv
21+ set outPath to item 1 of argv
22+ try
23+ set imgData to the clipboard as «class PNGf»
24+ set imgKind to "png"
25+ on error
26+ try
27+ set imgData to the clipboard as «class TIFF»
28+ set imgKind to "tiff"
29+ on error
30+ return "NO_IMAGE"
31+ end try
32+ end try
33+
34+ set outFile to POSIX file outPath
35+ set fileRef to open for access outFile with write permission
36+ try
37+ set eof of fileRef to 0
38+ write imgData to fileRef
39+ close access fileRef
40+ on error errMsg number errNum
41+ try
42+ close access fileRef
43+ end try
44+ error errMsg number errNum
45+ end try
46+ return imgKind
47+end run
48+`
49+
72 func ReadClipboardImagePNG() (string, []byte, bool, error) {50 func ReadClipboardImagePNG() (string, []byte, bool, error) {
73 dir := clipboardImageDir()51 dir := clipboardImageDir()
74 if err := os.MkdirAll(dir, 0o700); err != nil {52 if err := os.MkdirAll(dir, 0o700); err != nil {
75 return "", nil, false, err53 return "", nil, false, err
76 }54 }
77 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")55 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
78- cpath := C.CString(path)56+ rawPath := path + ".raw"
79- defer C.free(unsafe.Pointer(cpath))57+ defer os.Remove(rawPath)
80- var cerr *C.char58+
81- code := C.ZotWriteClipboardPNG(cpath, &cerr)59+ kind, err := writeClipboardImageData(rawPath)
82- if cerr != nil {60+ if err != nil {
83- defer C.free(unsafe.Pointer(cerr))61+ return "", nil, false, err
84 }62 }
85- if code == 1 {63+ if kind == "" {
86 return "", nil, false, nil64 return "", nil, false, nil
87 }65 }
88- if code != 0 {66+
89- msg := "could not read image from clipboard"67+ switch kind {
90- if cerr != nil {68+ case "png":
91- msg = C.GoString(cerr)69+ if err := os.Rename(rawPath, path); err != nil {
70+ return "", nil, false, err
71+ }
72+ case "tiff":
73+ if err := convertTIFFFileToPNG(rawPath, path); err != nil {
74+ return "", nil, false, err
92 }75 }
93- return "", nil, false, fmt.Errorf("%s", msg)76+ default:
77+ return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
94 }78 }
79+
95 data, err := os.ReadFile(path)80 data, err := os.ReadFile(path)
96 if err != nil {81 if err != nil {
97 return "", nil, false, err82 return "", nil, false, err
@@ -99,6 +84,46 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
99 return path, data, true, nil84 return path, data, true, nil
100 }85 }
101 86
87+func writeClipboardImageData(path string) (string, error) {
88+ cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
89+ out, err := cmd.CombinedOutput()
90+ trimmed := strings.TrimSpace(string(out))
91+ if err != nil {
92+ if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
93+ return "", nil
94+ }
95+ if trimmed == "" {
96+ return "", fmt.Errorf("osascript failed: %w", err)
97+ }
98+ return "", fmt.Errorf("osascript failed: %s", trimmed)
99+ }
100+ if trimmed == "NO_IMAGE" {
101+ return "", nil
102+ }
103+ return trimmed, nil
104+}
105+
106+func convertTIFFFileToPNG(srcPath, dstPath string) error {
107+ in, err := os.Open(srcPath)
108+ if err != nil {
109+ return err
110+ }
111+ defer in.Close()
112+
113+ img, err := tiff.Decode(in)
114+ if err != nil {
115+ return err
116+ }
117+
118+ out, err := os.Create(dstPath)
119+ if err != nil {
120+ return err
121+ }
122+ defer out.Close()
123+
124+ return png.Encode(out, img)
125+}
126+
102 func clipboardImageDir() string {127 func clipboardImageDir() string {
103 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {128 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
104 return filepath.Join("/tmp", "zot-clipboard-images")129 return filepath.Join("/tmp", "zot-clipboard-images")
M packages/tui/clipboard_stub.go +1 -5
@@ -1,11 +1,7 @@
1-//go:build !darwin || !cgo
1+//go:build !darwin
22
33 package tui
44
55 func ReadClipboardImagePNG() (string, []byte, bool, error) {
66 return "", nil, false, nil
77 }
8-
9-func ReadClipboardText() (string, bool, error) {
10- return "", false, nil
11-}
@@ -1,11 +1,7 @@
1-//go:build !darwin || !cgo1+//go:build !darwin
2 2
3 package tui3 package tui
4 4
5 func ReadClipboardImagePNG() (string, []byte, bool, error) {5 func ReadClipboardImagePNG() (string, []byte, bool, error) {
6 return "", nil, false, nil6 return "", nil, false, nil
7 }7 }
8-
9-func ReadClipboardText() (string, bool, error) {
10- return "", false, nil
11-}
D packages/tui/clipboard_text_darwin.go +0 -36
@@ -1,36 +0,0 @@
1-//go:build darwin && cgo
2-
3-package tui
4-
5-/*
6-#cgo CFLAGS: -x objective-c
7-#cgo LDFLAGS: -framework AppKit
8-#import <AppKit/AppKit.h>
9-#include <stdlib.h>
10-#include <string.h>
11-
12-static char* ZotReadClipboardString(void) {
13- @autoreleasepool {
14- NSPasteboard *pb = [NSPasteboard generalPasteboard];
15- NSString *str = [pb stringForType:NSPasteboardTypeString];
16- if (str == nil) return NULL;
17- const char *utf8 = [str UTF8String];
18- if (utf8 == NULL) return NULL;
19- char *out = malloc(strlen(utf8) + 1);
20- if (out != NULL) strcpy(out, utf8);
21- return out;
22- }
23-}
24-*/
25-import "C"
26-
27-import "unsafe"
28-
29-func ReadClipboardText() (string, bool, error) {
30- cstr := C.ZotReadClipboardString()
31- if cstr == nil {
32- return "", false, nil
33- }
34- defer C.free(unsafe.Pointer(cstr))
35- return C.GoString(cstr), true, nil
36-}
@@ -1,36 +0,0 @@
1-//go:build darwin && cgo
2-
3-package tui
4-
5-/*
6-#cgo CFLAGS: -x objective-c
7-#cgo LDFLAGS: -framework AppKit
8-#import <AppKit/AppKit.h>
9-#include <stdlib.h>
10-#include <string.h>
11-
12-static char* ZotReadClipboardString(void) {
13- @autoreleasepool {
14- NSPasteboard *pb = [NSPasteboard generalPasteboard];
15- NSString *str = [pb stringForType:NSPasteboardTypeString];
16- if (str == nil) return NULL;
17- const char *utf8 = [str UTF8String];
18- if (utf8 == NULL) return NULL;
19- char *out = malloc(strlen(utf8) + 1);
20- if (out != NULL) strcpy(out, utf8);
21- return out;
22- }
23-}
24-*/
25-import "C"
26-
27-import "unsafe"
28-
29-func ReadClipboardText() (string, bool, error) {
30- cstr := C.ZotReadClipboardString()
31- if cstr == nil {
32- return "", false, nil
33- }
34- defer C.free(unsafe.Pointer(cstr))
35- return C.GoString(cstr), true, nil
36-}

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