Add clipboard paste support
- commit
- 4a6d6915caa5c39ba024f96ff59577602b295496
- author
- mi-skam · · 2026-06-21 12:45 UTC
- committer
- mi-skam · · 2026-06-21 12:47 UTC
- parent
- 1a24a20
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
8 files · +238 -4
M README.md +1 -0
| @@ -576,6 +576,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||
| 576 | 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 | 577 | | `ctrl+d` | Exit on empty input. | | |
| 578 | 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 | 580 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 580 | 581 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 581 | 582 | ||
| @@ -576,6 +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+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). | | ||
| 580 | | `@` | 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. | | ||
| 581 | 582 | ||||
M packages/agent/modes/interactive.go +55 -1
| @@ -424,6 +424,8 @@ type Interactive struct { | |||
| 424 | 424 | // them (or when the dialog is dismissed via esc). | |
| 425 | 425 | pendingRescuePrompt string | |
| 426 | 426 | pendingRescueImages []provider.ImageBlock | |
| 427 | + | ||
| 428 | + | clipboardImages []provider.ImageBlock | |
| 427 | 429 | } | |
| 428 | 430 | ||
| 429 | 431 | // welcomeVersionDuration is how long the welcome banner shows the | |
| @@ -1952,6 +1954,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1952 | 1954 | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0 | |
| 1953 | 1955 | if hadInput { | |
| 1954 | 1956 | i.ed.Clear() | |
| 1957 | + | i.clipboardImages = nil | |
| 1955 | 1958 | i.suggest.Reset() | |
| 1956 | 1959 | if ag != nil { | |
| 1957 | 1960 | ag.DrainQueuedMessages() | |
| @@ -2021,6 +2024,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2021 | 2024 | i.rend.Clear() | |
| 2022 | 2025 | i.invalidate() | |
| 2023 | 2026 | return false | |
| 2027 | + | case tui.KeyPasteClipboard: | |
| 2028 | + | i.pasteClipboard() | |
| 2029 | + | return false | |
| 2024 | 2030 | case tui.KeyCtrlO: | |
| 2025 | 2031 | // Toggle expansion of collapsed tool results. Affects every tool | |
| 2026 | 2032 | // call in the transcript — press again to re-collapse. | |
| @@ -2215,6 +2221,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2215 | 2221 | i.inputHistoryIndex = -1 | |
| 2216 | 2222 | } | |
| 2217 | 2223 | ||
| 2224 | + | if k.Kind == tui.KeyEsc { | |
| 2225 | + | i.clipboardImages = nil | |
| 2226 | + | } | |
| 2218 | 2227 | if submit := i.ed.HandleKey(k); submit { | |
| 2219 | 2228 | // SubmitValue() expands any [pasted text #N +L lines] | |
| 2220 | 2229 | // placeholders back into their bodies; the raw Value() | |
| @@ -2225,6 +2234,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2225 | 2234 | if text == "" { | |
| 2226 | 2235 | return false | |
| 2227 | 2236 | } | |
| 2237 | + | images := append([]provider.ImageBlock(nil), i.clipboardImages...) | |
| 2238 | + | i.clipboardImages = nil | |
| 2228 | 2239 | i.ed.Clear() | |
| 2229 | 2240 | i.inputHistoryIndex = -1 | |
| 2230 | 2241 | i.suggest.Reset() | |
| @@ -2291,6 +2302,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2291 | 2302 | ag := i.agent | |
| 2292 | 2303 | i.mu.Unlock() | |
| 2293 | 2304 | if busy { | |
| 2305 | + | if len(images) > 0 { | |
| 2306 | + | i.mu.Lock() | |
| 2307 | + | i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish" | |
| 2308 | + | i.mu.Unlock() | |
| 2309 | + | i.invalidate() | |
| 2310 | + | return false | |
| 2311 | + | } | |
| 2294 | 2312 | if ag != nil { | |
| 2295 | 2313 | ag.QueueMessage(text) | |
| 2296 | 2314 | } else { | |
| @@ -2301,11 +2319,47 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2301 | 2319 | i.invalidate() | |
| 2302 | 2320 | return false | |
| 2303 | 2321 | } | |
| 2304 | - | i.startTurn(ctx, text) | |
| 2322 | + | i.startTurnWithImages(ctx, text, images) | |
| 2305 | 2323 | } | |
| 2306 | 2324 | return false | |
| 2307 | 2325 | } | |
| 2308 | 2326 | ||
| 2327 | + | func (i *Interactive) pasteClipboard() { | |
| 2328 | + | path, data, ok, err := tui.ReadClipboardImagePNG() | |
| 2329 | + | if err != nil { | |
| 2330 | + | i.mu.Lock() | |
| 2331 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 2332 | + | i.statusOK = "" | |
| 2333 | + | i.mu.Unlock() | |
| 2334 | + | return | |
| 2335 | + | } | |
| 2336 | + | if ok { | |
| 2337 | + | 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 | |
| 2341 | + | i.statusErr = "" | |
| 2342 | + | i.mu.Unlock() | |
| 2343 | + | return | |
| 2344 | + | } | |
| 2345 | + | text, textOK, err := tui.ReadClipboardText() | |
| 2346 | + | 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}) | |
| 2359 | + | i.statusOK = "" | |
| 2360 | + | i.statusErr = "" | |
| 2361 | + | } | |
| 2362 | + | ||
| 2309 | 2363 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 2310 | 2364 | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 2311 | 2365 | return false | |
| @@ -424,6 +424,8 @@ type Interactive struct { | |||||
| 424 | // them (or when the dialog is dismissed via esc). | 424 | // them (or when the dialog is dismissed via esc). | ||
| 425 | pendingRescuePrompt string | 425 | pendingRescuePrompt string | ||
| 426 | pendingRescueImages []provider.ImageBlock | 426 | pendingRescueImages []provider.ImageBlock | ||
| 427 | + | ||||
| 428 | + | clipboardImages []provider.ImageBlock | |||
| 427 | } | 429 | } | ||
| 428 | 430 | ||||
| 429 | // welcomeVersionDuration is how long the welcome banner shows the | 431 | // welcomeVersionDuration is how long the welcome banner shows the | ||
| @@ -1952,6 +1954,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1952 | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0 | 1954 | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0 | ||
| 1953 | if hadInput { | 1955 | if hadInput { | ||
| 1954 | i.ed.Clear() | 1956 | i.ed.Clear() | ||
| 1957 | + | i.clipboardImages = nil | |||
| 1955 | i.suggest.Reset() | 1958 | i.suggest.Reset() | ||
| 1956 | if ag != nil { | 1959 | if ag != nil { | ||
| 1957 | ag.DrainQueuedMessages() | 1960 | ag.DrainQueuedMessages() | ||
| @@ -2021,6 +2024,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2021 | i.rend.Clear() | 2024 | i.rend.Clear() | ||
| 2022 | i.invalidate() | 2025 | i.invalidate() | ||
| 2023 | return false | 2026 | return false | ||
| 2027 | + | case tui.KeyPasteClipboard: | |||
| 2028 | + | i.pasteClipboard() | |||
| 2029 | + | return false | |||
| 2024 | case tui.KeyCtrlO: | 2030 | case tui.KeyCtrlO: | ||
| 2025 | // Toggle expansion of collapsed tool results. Affects every tool | 2031 | // Toggle expansion of collapsed tool results. Affects every tool | ||
| 2026 | // call in the transcript — press again to re-collapse. | 2032 | // call in the transcript — press again to re-collapse. | ||
| @@ -2215,6 +2221,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2215 | i.inputHistoryIndex = -1 | 2221 | i.inputHistoryIndex = -1 | ||
| 2216 | } | 2222 | } | ||
| 2217 | 2223 | ||||
| 2224 | + | if k.Kind == tui.KeyEsc { | |||
| 2225 | + | i.clipboardImages = nil | |||
| 2226 | + | } | |||
| 2218 | if submit := i.ed.HandleKey(k); submit { | 2227 | if submit := i.ed.HandleKey(k); submit { | ||
| 2219 | // SubmitValue() expands any [pasted text #N +L lines] | 2228 | // SubmitValue() expands any [pasted text #N +L lines] | ||
| 2220 | // placeholders back into their bodies; the raw Value() | 2229 | // placeholders back into their bodies; the raw Value() | ||
| @@ -2225,6 +2234,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2225 | if text == "" { | 2234 | if text == "" { | ||
| 2226 | return false | 2235 | return false | ||
| 2227 | } | 2236 | } | ||
| 2237 | + | images := append([]provider.ImageBlock(nil), i.clipboardImages...) | |||
| 2238 | + | i.clipboardImages = nil | |||
| 2228 | i.ed.Clear() | 2239 | i.ed.Clear() | ||
| 2229 | i.inputHistoryIndex = -1 | 2240 | i.inputHistoryIndex = -1 | ||
| 2230 | i.suggest.Reset() | 2241 | i.suggest.Reset() | ||
| @@ -2291,6 +2302,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2291 | ag := i.agent | 2302 | ag := i.agent | ||
| 2292 | i.mu.Unlock() | 2303 | i.mu.Unlock() | ||
| 2293 | if busy { | 2304 | if busy { | ||
| 2305 | + | if len(images) > 0 { | |||
| 2306 | + | i.mu.Lock() | |||
| 2307 | + | i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish" | |||
| 2308 | + | i.mu.Unlock() | |||
| 2309 | + | i.invalidate() | |||
| 2310 | + | return false | |||
| 2311 | + | } | |||
| 2294 | if ag != nil { | 2312 | if ag != nil { | ||
| 2295 | ag.QueueMessage(text) | 2313 | ag.QueueMessage(text) | ||
| 2296 | } else { | 2314 | } else { | ||
| @@ -2301,11 +2319,47 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2301 | i.invalidate() | 2319 | i.invalidate() | ||
| 2302 | return false | 2320 | return false | ||
| 2303 | } | 2321 | } | ||
| 2304 | - | i.startTurn(ctx, text) | 2322 | + | i.startTurnWithImages(ctx, text, images) |
| 2305 | } | 2323 | } | ||
| 2306 | return false | 2324 | return false | ||
| 2307 | } | 2325 | } | ||
| 2308 | 2326 | ||||
| 2327 | + | func (i *Interactive) pasteClipboard() { | |||
| 2328 | + | path, data, ok, err := tui.ReadClipboardImagePNG() | |||
| 2329 | + | if err != nil { | |||
| 2330 | + | i.mu.Lock() | |||
| 2331 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |||
| 2332 | + | i.statusOK = "" | |||
| 2333 | + | i.mu.Unlock() | |||
| 2334 | + | return | |||
| 2335 | + | } | |||
| 2336 | + | if ok { | |||
| 2337 | + | 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 | |||
| 2341 | + | i.statusErr = "" | |||
| 2342 | + | i.mu.Unlock() | |||
| 2343 | + | return | |||
| 2344 | + | } | |||
| 2345 | + | text, textOK, err := tui.ReadClipboardText() | |||
| 2346 | + | 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}) | |||
| 2359 | + | i.statusOK = "" | |||
| 2360 | + | i.statusErr = "" | |||
| 2361 | + | } | |||
| 2362 | + | ||||
| 2309 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | 2363 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | ||
| 2310 | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | 2364 | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | ||
| 2311 | return false | 2365 | return false | ||
A packages/tui/clipboard_darwin.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 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* 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 ( | |
| 63 | + | "crypto/rand" | |
| 64 | + | "encoding/hex" | |
| 65 | + | "fmt" | |
| 66 | + | "os" | |
| 67 | + | "path/filepath" | |
| 68 | + | "time" | |
| 69 | + | "unsafe" | |
| 70 | + | ) | |
| 71 | + | ||
| 72 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 73 | + | dir := clipboardImageDir() | |
| 74 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 75 | + | return "", nil, false, err | |
| 76 | + | } | |
| 77 | + | 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)) | |
| 84 | + | } | |
| 85 | + | if code == 1 { | |
| 86 | + | return "", nil, false, nil | |
| 87 | + | } | |
| 88 | + | if code != 0 { | |
| 89 | + | msg := "could not read image from clipboard" | |
| 90 | + | if cerr != nil { | |
| 91 | + | msg = C.GoString(cerr) | |
| 92 | + | } | |
| 93 | + | return "", nil, false, fmt.Errorf("%s", msg) | |
| 94 | + | } | |
| 95 | + | data, err := os.ReadFile(path) | |
| 96 | + | if err != nil { | |
| 97 | + | return "", nil, false, err | |
| 98 | + | } | |
| 99 | + | return path, data, true, nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func clipboardImageDir() string { | |
| 103 | + | if info, err := os.Stat("/tmp"); err == nil && info.IsDir() { | |
| 104 | + | return filepath.Join("/tmp", "zot-clipboard-images") | |
| 105 | + | } | |
| 106 | + | return filepath.Join(os.TempDir(), "zot-clipboard-images") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func randomHex(n int) string { | |
| 110 | + | buf := make([]byte, n) | |
| 111 | + | if _, err := rand.Read(buf); err != nil { | |
| 112 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |
| 113 | + | } | |
| 114 | + | return hex.EncodeToString(buf) | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 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* 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 ( | |||
| 63 | + | "crypto/rand" | |||
| 64 | + | "encoding/hex" | |||
| 65 | + | "fmt" | |||
| 66 | + | "os" | |||
| 67 | + | "path/filepath" | |||
| 68 | + | "time" | |||
| 69 | + | "unsafe" | |||
| 70 | + | ) | |||
| 71 | + | ||||
| 72 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 73 | + | dir := clipboardImageDir() | |||
| 74 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 75 | + | return "", nil, false, err | |||
| 76 | + | } | |||
| 77 | + | 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)) | |||
| 84 | + | } | |||
| 85 | + | if code == 1 { | |||
| 86 | + | return "", nil, false, nil | |||
| 87 | + | } | |||
| 88 | + | if code != 0 { | |||
| 89 | + | msg := "could not read image from clipboard" | |||
| 90 | + | if cerr != nil { | |||
| 91 | + | msg = C.GoString(cerr) | |||
| 92 | + | } | |||
| 93 | + | return "", nil, false, fmt.Errorf("%s", msg) | |||
| 94 | + | } | |||
| 95 | + | data, err := os.ReadFile(path) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return "", nil, false, err | |||
| 98 | + | } | |||
| 99 | + | return path, data, true, nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func clipboardImageDir() string { | |||
| 103 | + | if info, err := os.Stat("/tmp"); err == nil && info.IsDir() { | |||
| 104 | + | return filepath.Join("/tmp", "zot-clipboard-images") | |||
| 105 | + | } | |||
| 106 | + | return filepath.Join(os.TempDir(), "zot-clipboard-images") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func randomHex(n int) string { | |||
| 110 | + | buf := make([]byte, n) | |||
| 111 | + | if _, err := rand.Read(buf); err != nil { | |||
| 112 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 113 | + | } | |||
| 114 | + | return hex.EncodeToString(buf) | |||
| 115 | + | } | |||
A packages/tui/clipboard_stub.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | //go:build !darwin || !cgo | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 6 | + | return "", nil, false, nil | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | func ReadClipboardText() (string, bool, error) { | |
| 10 | + | return "", false, nil | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | //go:build !darwin || !cgo | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 6 | + | return "", nil, false, nil | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | func ReadClipboardText() (string, bool, error) { | |||
| 10 | + | return "", false, nil | |||
| 11 | + | } | |||
A packages/tui/clipboard_text_darwin.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 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 | + | } | |||
M packages/tui/input.go +5 -0
| @@ -44,6 +44,7 @@ const ( | |||
| 44 | 44 | KeyCtrlW | |
| 45 | 45 | KeyCtrlO | |
| 46 | 46 | KeyPaste | |
| 47 | + | KeyPasteClipboard | |
| 47 | 48 | KeyMouseWheelUp | |
| 48 | 49 | KeyMouseWheelDown | |
| 49 | 50 | KeyUnknown | |
| @@ -90,6 +91,8 @@ func (r *Reader) Read() (Key, error) { | |||
| 90 | 91 | return Key{Kind: KeyCtrlW}, nil | |
| 91 | 92 | case b == 0x0f: | |
| 92 | 93 | return Key{Kind: KeyCtrlO}, nil | |
| 94 | + | case b == 0x16: | |
| 95 | + | return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil | |
| 93 | 96 | case b == '\r', b == '\n': | |
| 94 | 97 | return Key{Kind: KeyEnter}, nil | |
| 95 | 98 | case b == '\t': | |
| @@ -379,6 +382,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 379 | 382 | return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 380 | 383 | case 'o', 'O': | |
| 381 | 384 | return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 385 | + | case 'v', 'V': | |
| 386 | + | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 382 | 387 | } | |
| 383 | 388 | } | |
| 384 | 389 | return Key{}, false | |
| @@ -44,6 +44,7 @@ const ( | |||||
| 44 | KeyCtrlW | 44 | KeyCtrlW | ||
| 45 | KeyCtrlO | 45 | KeyCtrlO | ||
| 46 | KeyPaste | 46 | KeyPaste | ||
| 47 | + | KeyPasteClipboard | |||
| 47 | KeyMouseWheelUp | 48 | KeyMouseWheelUp | ||
| 48 | KeyMouseWheelDown | 49 | KeyMouseWheelDown | ||
| 49 | KeyUnknown | 50 | KeyUnknown | ||
| @@ -90,6 +91,8 @@ func (r *Reader) Read() (Key, error) { | |||||
| 90 | return Key{Kind: KeyCtrlW}, nil | 91 | return Key{Kind: KeyCtrlW}, nil | ||
| 91 | case b == 0x0f: | 92 | case b == 0x0f: | ||
| 92 | return Key{Kind: KeyCtrlO}, nil | 93 | return Key{Kind: KeyCtrlO}, nil | ||
| 94 | + | case b == 0x16: | |||
| 95 | + | return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil | |||
| 93 | case b == '\r', b == '\n': | 96 | case b == '\r', b == '\n': | ||
| 94 | return Key{Kind: KeyEnter}, nil | 97 | return Key{Kind: KeyEnter}, nil | ||
| 95 | case b == '\t': | 98 | case b == '\t': | ||
| @@ -379,6 +382,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 379 | return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true | 382 | return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 380 | case 'o', 'O': | 383 | case 'o', 'O': | ||
| 381 | return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true | 384 | return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 385 | + | case 'v', 'V': | |||
| 386 | + | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 382 | } | 387 | } | ||
| 383 | } | 388 | } | ||
| 384 | return Key{}, false | 389 | return Key{}, false | ||
M packages/tui/input_test.go +14 -0
| @@ -23,6 +23,20 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) { | |||
| 23 | 23 | } | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |
| 27 | + | k := readKey(t, "\x16") | |
| 28 | + | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| 29 | + | t.Fatalf("Read kind=%v ctrl=%v, want ctrl+v clipboard paste", k.Kind, k.Ctrl) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | |
| 34 | + | k := readKey(t, "\x1b[118;5u") | |
| 35 | + | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| 36 | + | t.Fatalf("Read kind=%v ctrl=%v, want enhanced ctrl+v clipboard paste", k.Kind, k.Ctrl) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 26 | 40 | func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |
| 27 | 41 | k := readKey(t, "\x1b[27;5;99~") | |
| 28 | 42 | if k.Kind != KeyCtrlC || !k.Ctrl { | |
| @@ -23,6 +23,20 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) { | |||||
| 23 | } | 23 | } | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | + | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |||
| 27 | + | k := readKey(t, "\x16") | |||
| 28 | + | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |||
| 29 | + | t.Fatalf("Read kind=%v ctrl=%v, want ctrl+v clipboard paste", k.Kind, k.Ctrl) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | |||
| 34 | + | k := readKey(t, "\x1b[118;5u") | |||
| 35 | + | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |||
| 36 | + | t.Fatalf("Read kind=%v ctrl=%v, want enhanced ctrl+v clipboard paste", k.Kind, k.Ctrl) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 26 | func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | 40 | func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | ||
| 27 | k := readKey(t, "\x1b[27;5;99~") | 41 | k := readKey(t, "\x1b[27;5;99~") | ||
| 28 | if k.Kind != KeyCtrlC || !k.Ctrl { | 42 | if k.Kind != KeyCtrlC || !k.Ctrl { | ||
M packages/tui/view.go +1 -3
| @@ -631,9 +631,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 631 | 631 | } | |
| 632 | 632 | row := func(content string) string { | |
| 633 | 633 | inner := strings.Repeat(" ", leftGutter) + content | |
| 634 | - | padded := v.Theme.UserBubble(inner, width-2) | |
| 635 | - | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 636 | - | return bar + padded | |
| 634 | + | return v.Theme.UserBubbleRow(inner, width) | |
| 637 | 635 | } | |
| 638 | 636 | var bubble []string | |
| 639 | 637 | for _, c := range m.Content { | |
| @@ -631,9 +631,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 631 | } | 631 | } | ||
| 632 | row := func(content string) string { | 632 | row := func(content string) string { | ||
| 633 | inner := strings.Repeat(" ", leftGutter) + content | 633 | inner := strings.Repeat(" ", leftGutter) + content | ||
| 634 | - | padded := v.Theme.UserBubble(inner, width-2) | 634 | + | return v.Theme.UserBubbleRow(inner, width) |
| 635 | - | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |||
| 636 | - | return bar + padded | |||
| 637 | } | 635 | } | ||
| 638 | var bubble []string | 636 | var bubble []string | ||
| 639 | for _, c := range m.Content { | 637 | for _, c := range m.Content { | ||