Merge pull request #43 from mi-skam/clipboard-paste-support
9 files · +491 -6
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 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. | | |
| 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 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. | | |||
| 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/btw_dialog.go +6 -4
| @@ -378,12 +378,14 @@ func (d *btwDialog) CursorPos(width int) (row, col int) { | |||
| 378 | 378 | } | |
| 379 | 379 | // Reproduce render's structure to find where the editor sits. | |
| 380 | 380 | // Note: the parent (interactive.go) wraps every dialog with | |
| 381 | - | // padDialogFrame, which injects a blank row right after the | |
| 382 | - | // frame header. We have to count that injected row here too; | |
| 383 | - | // otherwise the reported cursor lands one row above the editor. | |
| 381 | + | // padDialogFrame. It only injects a blank row after the frame | |
| 382 | + | // header when Render did not already put one there. With existing | |
| 383 | + | // turns or a loading spinner, Render's first body row is already | |
| 384 | + | // blank, so counting an extra pad row would place the cursor one | |
| 385 | + | // row too low. | |
| 384 | 386 | editorOffset := 1 // header | |
| 385 | - | editorOffset++ // padDialogFrame's post-header blank | |
| 386 | 387 | if len(d.turns) == 0 && !d.loading { | |
| 388 | + | editorOffset++ // padDialogFrame's post-header blank | |
| 387 | 389 | editorOffset++ // muted "ask anything..." line | |
| 388 | 390 | } | |
| 389 | 391 | for _, t := range d.turns { | |
| @@ -378,12 +378,14 @@ func (d *btwDialog) CursorPos(width int) (row, col int) { | |||||
| 378 | } | 378 | } | ||
| 379 | // Reproduce render's structure to find where the editor sits. | 379 | // Reproduce render's structure to find where the editor sits. | ||
| 380 | // Note: the parent (interactive.go) wraps every dialog with | 380 | // Note: the parent (interactive.go) wraps every dialog with | ||
| 381 | - | // padDialogFrame, which injects a blank row right after the | 381 | + | // padDialogFrame. It only injects a blank row after the frame |
| 382 | - | // frame header. We have to count that injected row here too; | 382 | + | // header when Render did not already put one there. With existing |
| 383 | - | // otherwise the reported cursor lands one row above the editor. | 383 | + | // turns or a loading spinner, Render's first body row is already |
| 384 | + | // blank, so counting an extra pad row would place the cursor one | |||
| 385 | + | // row too low. | |||
| 384 | editorOffset := 1 // header | 386 | editorOffset := 1 // header | ||
| 385 | - | editorOffset++ // padDialogFrame's post-header blank | |||
| 386 | if len(d.turns) == 0 && !d.loading { | 387 | if len(d.turns) == 0 && !d.loading { | ||
| 388 | + | editorOffset++ // padDialogFrame's post-header blank | |||
| 387 | editorOffset++ // muted "ask anything..." line | 389 | editorOffset++ // muted "ask anything..." line | ||
| 388 | } | 390 | } | ||
| 389 | for _, t := range d.turns { | 391 | for _, t := range d.turns { | ||
A packages/agent/modes/clipboard_images.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 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 | + | if len(pending) == 0 { | |
| 16 | + | return text, nil | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | out := text | |
| 20 | + | images := make([]provider.ImageBlock, 0, len(pending)) | |
| 21 | + | for _, item := range pending { | |
| 22 | + | if item.Marker == "" || !strings.Contains(out, item.Marker) { | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | out = removeClipboardMarker(out, item.Marker) | |
| 26 | + | images = append(images, item.Image) | |
| 27 | + | } | |
| 28 | + | return strings.TrimSpace(out), images | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func removeClipboardMarker(text, marker string) string { | |
| 32 | + | for { | |
| 33 | + | idx := strings.Index(text, marker) | |
| 34 | + | if idx < 0 { | |
| 35 | + | return text | |
| 36 | + | } | |
| 37 | + | end := idx + len(marker) | |
| 38 | + | prevInline := idx > 0 && isInlineWhitespace(text[idx-1]) | |
| 39 | + | nextInline := end < len(text) && isInlineWhitespace(text[end]) | |
| 40 | + | prevLineBreak := idx == 0 || idx > 0 && isLineBreak(text[idx-1]) | |
| 41 | + | nextLineBreak := end == len(text) || end < len(text) && isLineBreak(text[end]) | |
| 42 | + | switch { | |
| 43 | + | case prevInline && nextInline: | |
| 44 | + | text = text[:idx] + text[end+1:] | |
| 45 | + | case prevInline && nextLineBreak: | |
| 46 | + | text = text[:idx-1] + text[end:] | |
| 47 | + | case prevLineBreak && nextInline: | |
| 48 | + | text = text[:idx] + text[end+1:] | |
| 49 | + | default: | |
| 50 | + | text = text[:idx] + text[end:] | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func isInlineWhitespace(b byte) bool { | |
| 56 | + | return b == ' ' || b == '\t' | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func isLineBreak(b byte) bool { | |
| 60 | + | return b == '\n' || b == '\r' | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 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 | + | if len(pending) == 0 { | |||
| 16 | + | return text, nil | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | out := text | |||
| 20 | + | images := make([]provider.ImageBlock, 0, len(pending)) | |||
| 21 | + | for _, item := range pending { | |||
| 22 | + | if item.Marker == "" || !strings.Contains(out, item.Marker) { | |||
| 23 | + | continue | |||
| 24 | + | } | |||
| 25 | + | out = removeClipboardMarker(out, item.Marker) | |||
| 26 | + | images = append(images, item.Image) | |||
| 27 | + | } | |||
| 28 | + | return strings.TrimSpace(out), images | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func removeClipboardMarker(text, marker string) string { | |||
| 32 | + | for { | |||
| 33 | + | idx := strings.Index(text, marker) | |||
| 34 | + | if idx < 0 { | |||
| 35 | + | return text | |||
| 36 | + | } | |||
| 37 | + | end := idx + len(marker) | |||
| 38 | + | prevInline := idx > 0 && isInlineWhitespace(text[idx-1]) | |||
| 39 | + | nextInline := end < len(text) && isInlineWhitespace(text[end]) | |||
| 40 | + | prevLineBreak := idx == 0 || idx > 0 && isLineBreak(text[idx-1]) | |||
| 41 | + | nextLineBreak := end == len(text) || end < len(text) && isLineBreak(text[end]) | |||
| 42 | + | switch { | |||
| 43 | + | case prevInline && nextInline: | |||
| 44 | + | text = text[:idx] + text[end+1:] | |||
| 45 | + | case prevInline && nextLineBreak: | |||
| 46 | + | text = text[:idx-1] + text[end:] | |||
| 47 | + | case prevLineBreak && nextInline: | |||
| 48 | + | text = text[:idx] + text[end+1:] | |||
| 49 | + | default: | |||
| 50 | + | text = text[:idx] + text[end:] | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func isInlineWhitespace(b byte) bool { | |||
| 56 | + | return b == ' ' || b == '\t' | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func isLineBreak(b byte) bool { | |||
| 60 | + | return b == '\n' || b == '\r' | |||
| 61 | + | } | |||
A packages/agent/modes/clipboard_images_test.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 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 | + | } | |
| 93 | + | ||
| 94 | + | func TestPreparePromptWithClipboardImagesNoPendingPreservesWhitespace(t *testing.T) { | |
| 95 | + | input := "please review:\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n" | |
| 96 | + | ||
| 97 | + | text, images := preparePromptWithClipboardImages(input, nil) | |
| 98 | + | ||
| 99 | + | if text != input { | |
| 100 | + | t.Fatalf("text changed without pending images:\n got %q\nwant %q", text, input) | |
| 101 | + | } | |
| 102 | + | if images != nil { | |
| 103 | + | t.Fatalf("images = %#v, want nil", images) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestPreparePromptWithClipboardImagesPreservesMultilinePrompt(t *testing.T) { | |
| 108 | + | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |
| 109 | + | input := "compare this image [clipboard image #1]\n\nwith this code:\n\treturn 1" | |
| 110 | + | want := "compare this image\n\nwith this code:\n\treturn 1" | |
| 111 | + | ||
| 112 | + | text, images := preparePromptWithClipboardImages(input, pending) | |
| 113 | + | ||
| 114 | + | if text != want { | |
| 115 | + | t.Fatalf("text = %q, want %q", text, want) | |
| 116 | + | } | |
| 117 | + | if len(images) != 1 { | |
| 118 | + | t.Fatalf("len(images) = %d, want 1", len(images)) | |
| 119 | + | } | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 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 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestPreparePromptWithClipboardImagesNoPendingPreservesWhitespace(t *testing.T) { | |||
| 95 | + | input := "please review:\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n" | |||
| 96 | + | ||||
| 97 | + | text, images := preparePromptWithClipboardImages(input, nil) | |||
| 98 | + | ||||
| 99 | + | if text != input { | |||
| 100 | + | t.Fatalf("text changed without pending images:\n got %q\nwant %q", text, input) | |||
| 101 | + | } | |||
| 102 | + | if images != nil { | |||
| 103 | + | t.Fatalf("images = %#v, want nil", images) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestPreparePromptWithClipboardImagesPreservesMultilinePrompt(t *testing.T) { | |||
| 108 | + | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |||
| 109 | + | input := "compare this image [clipboard image #1]\n\nwith this code:\n\treturn 1" | |||
| 110 | + | want := "compare this image\n\nwith this code:\n\treturn 1" | |||
| 111 | + | ||||
| 112 | + | text, images := preparePromptWithClipboardImages(input, pending) | |||
| 113 | + | ||||
| 114 | + | if text != want { | |||
| 115 | + | t.Fatalf("text = %q, want %q", text, want) | |||
| 116 | + | } | |||
| 117 | + | if len(images) != 1 { | |||
| 118 | + | t.Fatalf("len(images) = %d, want 1", len(images)) | |||
| 119 | + | } | |||
| 120 | + | } | |||
M packages/agent/modes/interactive.go +48 -2
| @@ -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 []clipboardImageAttachment | |
| 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() | |
| @@ -2222,9 +2231,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2222 | 2231 | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | |
| 2223 | 2232 | // Expand [file:name] and [dir:name/] chips to full paths. | |
| 2224 | 2233 | text = expandFileChips(text, i.cfg.CWD) | |
| 2225 | - | if text == "" { | |
| 2234 | + | text, images := preparePromptWithClipboardImages(text, i.clipboardImages) | |
| 2235 | + | if text == "" && len(images) == 0 { | |
| 2226 | 2236 | return false | |
| 2227 | 2237 | } | |
| 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,39 @@ 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 | + | _, 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 | + | 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 = "" | |
| 2345 | + | i.statusErr = "" | |
| 2346 | + | i.mu.Unlock() | |
| 2347 | + | return | |
| 2348 | + | } | |
| 2349 | + | i.mu.Lock() | |
| 2350 | + | i.statusErr = "clipboard does not contain an image" | |
| 2351 | + | i.statusOK = "" | |
| 2352 | + | i.mu.Unlock() | |
| 2353 | + | } | |
| 2354 | + | ||
| 2309 | 2355 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 2310 | 2356 | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 2311 | 2357 | 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 []clipboardImageAttachment | |||
| 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() | ||
| @@ -2222,9 +2231,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2222 | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | 2231 | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | ||
| 2223 | // Expand [file:name] and [dir:name/] chips to full paths. | 2232 | // Expand [file:name] and [dir:name/] chips to full paths. | ||
| 2224 | text = expandFileChips(text, i.cfg.CWD) | 2233 | text = expandFileChips(text, i.cfg.CWD) | ||
| 2225 | - | if text == "" { | 2234 | + | text, images := preparePromptWithClipboardImages(text, i.clipboardImages) |
| 2235 | + | if text == "" && len(images) == 0 { | |||
| 2226 | return false | 2236 | return false | ||
| 2227 | } | 2237 | } | ||
| 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,39 @@ 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 | + | _, 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 | + | 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 = "" | |||
| 2345 | + | i.statusErr = "" | |||
| 2346 | + | i.mu.Unlock() | |||
| 2347 | + | return | |||
| 2348 | + | } | |||
| 2349 | + | i.mu.Lock() | |||
| 2350 | + | i.statusErr = "clipboard does not contain an image" | |||
| 2351 | + | i.statusOK = "" | |||
| 2352 | + | i.mu.Unlock() | |||
| 2353 | + | } | |||
| 2354 | + | ||||
| 2309 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | 2355 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | ||
| 2310 | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | 2356 | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | ||
| 2311 | return false | 2357 | return false | ||
A packages/tui/clipboard_darwin.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "fmt" | |
| 9 | + | "image/png" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "golang.org/x/image/tiff" | |
| 17 | + | ) | |
| 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 | + | ||
| 50 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 51 | + | dir := clipboardImageDir() | |
| 52 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 53 | + | return "", nil, false, err | |
| 54 | + | } | |
| 55 | + | path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png") | |
| 56 | + | rawPath := path + ".raw" | |
| 57 | + | defer os.Remove(rawPath) | |
| 58 | + | ||
| 59 | + | kind, err := writeClipboardImageData(rawPath) | |
| 60 | + | if err != nil { | |
| 61 | + | return "", nil, false, err | |
| 62 | + | } | |
| 63 | + | if kind == "" { | |
| 64 | + | return "", nil, false, nil | |
| 65 | + | } | |
| 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 | |
| 75 | + | } | |
| 76 | + | default: | |
| 77 | + | clipPath, ok := findClipboardImagePath(kind) | |
| 78 | + | if !ok { | |
| 79 | + | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |
| 80 | + | } | |
| 81 | + | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | |
| 82 | + | return "", nil, false, err | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | data, err := os.ReadFile(path) | |
| 87 | + | if err != nil { | |
| 88 | + | return "", nil, false, err | |
| 89 | + | } | |
| 90 | + | return path, data, true, nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func writeClipboardImageData(path string) (string, error) { | |
| 94 | + | cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path) | |
| 95 | + | out, err := cmd.CombinedOutput() | |
| 96 | + | trimmed := strings.TrimSpace(string(out)) | |
| 97 | + | if err != nil { | |
| 98 | + | if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") { | |
| 99 | + | return "", nil | |
| 100 | + | } | |
| 101 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |
| 102 | + | return kind, nil | |
| 103 | + | } | |
| 104 | + | if path, ok := findClipboardImagePath(trimmed); ok { | |
| 105 | + | return path, nil | |
| 106 | + | } | |
| 107 | + | if trimmed == "" { | |
| 108 | + | return "", fmt.Errorf("osascript failed: %w", err) | |
| 109 | + | } | |
| 110 | + | return "", fmt.Errorf("osascript failed: %s", trimmed) | |
| 111 | + | } | |
| 112 | + | if trimmed == "NO_IMAGE" { | |
| 113 | + | return "", nil | |
| 114 | + | } | |
| 115 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |
| 116 | + | return kind, nil | |
| 117 | + | } | |
| 118 | + | return trimmed, nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func clipboardImageKind(s string) (string, bool) { | |
| 122 | + | for _, line := range strings.Split(s, "\n") { | |
| 123 | + | switch strings.TrimSpace(line) { | |
| 124 | + | case "png": | |
| 125 | + | return "png", true | |
| 126 | + | case "tiff": | |
| 127 | + | return "tiff", true | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return "", false | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | |
| 134 | + | switch strings.ToLower(filepath.Ext(srcPath)) { | |
| 135 | + | case ".png": | |
| 136 | + | data, err := os.ReadFile(srcPath) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | return os.WriteFile(dstPath, data, 0o600) | |
| 141 | + | case ".tif", ".tiff": | |
| 142 | + | return convertTIFFFileToPNG(srcPath, dstPath) | |
| 143 | + | default: | |
| 144 | + | return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func findClipboardImagePath(s string) (string, bool) { | |
| 149 | + | if p, ok := clipboardImagePath(s); ok { | |
| 150 | + | return p, true | |
| 151 | + | } | |
| 152 | + | for _, ext := range []string{".png", ".tiff", ".tif"} { | |
| 153 | + | lower := strings.ToLower(s) | |
| 154 | + | end := strings.Index(lower, ext) | |
| 155 | + | if end < 0 { | |
| 156 | + | continue | |
| 157 | + | } | |
| 158 | + | end += len(ext) | |
| 159 | + | start := strings.LastIndex(s[:end], "/") | |
| 160 | + | if start < 0 { | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | for start > 0 { | |
| 164 | + | c := s[start-1] | |
| 165 | + | if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' { | |
| 166 | + | break | |
| 167 | + | } | |
| 168 | + | start-- | |
| 169 | + | } | |
| 170 | + | if p, ok := clipboardImagePath(s[start:end]); ok { | |
| 171 | + | return p, true | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return "", false | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func clipboardImagePath(s string) (string, bool) { | |
| 178 | + | p := strings.TrimSpace(s) | |
| 179 | + | p = strings.Trim(p, "'\"") | |
| 180 | + | if p == "" { | |
| 181 | + | return "", false | |
| 182 | + | } | |
| 183 | + | info, err := os.Stat(p) | |
| 184 | + | if err != nil || info.IsDir() { | |
| 185 | + | return "", false | |
| 186 | + | } | |
| 187 | + | switch strings.ToLower(filepath.Ext(p)) { | |
| 188 | + | case ".png", ".tif", ".tiff": | |
| 189 | + | return p, true | |
| 190 | + | default: | |
| 191 | + | return "", false | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func convertTIFFFileToPNG(srcPath, dstPath string) error { | |
| 196 | + | in, err := os.Open(srcPath) | |
| 197 | + | if err != nil { | |
| 198 | + | return err | |
| 199 | + | } | |
| 200 | + | defer in.Close() | |
| 201 | + | ||
| 202 | + | img, err := tiff.Decode(in) | |
| 203 | + | if err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | out, err := os.Create(dstPath) | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | defer out.Close() | |
| 212 | + | ||
| 213 | + | return png.Encode(out, img) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func clipboardImageDir() string { | |
| 217 | + | if info, err := os.Stat("/tmp"); err == nil && info.IsDir() { | |
| 218 | + | return filepath.Join("/tmp", "zot-clipboard-images") | |
| 219 | + | } | |
| 220 | + | return filepath.Join(os.TempDir(), "zot-clipboard-images") | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func randomHex(n int) string { | |
| 224 | + | buf := make([]byte, n) | |
| 225 | + | if _, err := rand.Read(buf); err != nil { | |
| 226 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |
| 227 | + | } | |
| 228 | + | return hex.EncodeToString(buf) | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | //go:build darwin | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "crypto/rand" | |||
| 7 | + | "encoding/hex" | |||
| 8 | + | "fmt" | |||
| 9 | + | "image/png" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "golang.org/x/image/tiff" | |||
| 17 | + | ) | |||
| 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 | + | ||||
| 50 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 51 | + | dir := clipboardImageDir() | |||
| 52 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 53 | + | return "", nil, false, err | |||
| 54 | + | } | |||
| 55 | + | path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png") | |||
| 56 | + | rawPath := path + ".raw" | |||
| 57 | + | defer os.Remove(rawPath) | |||
| 58 | + | ||||
| 59 | + | kind, err := writeClipboardImageData(rawPath) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return "", nil, false, err | |||
| 62 | + | } | |||
| 63 | + | if kind == "" { | |||
| 64 | + | return "", nil, false, nil | |||
| 65 | + | } | |||
| 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 | |||
| 75 | + | } | |||
| 76 | + | default: | |||
| 77 | + | clipPath, ok := findClipboardImagePath(kind) | |||
| 78 | + | if !ok { | |||
| 79 | + | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |||
| 80 | + | } | |||
| 81 | + | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | |||
| 82 | + | return "", nil, false, err | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | data, err := os.ReadFile(path) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return "", nil, false, err | |||
| 89 | + | } | |||
| 90 | + | return path, data, true, nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func writeClipboardImageData(path string) (string, error) { | |||
| 94 | + | cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path) | |||
| 95 | + | out, err := cmd.CombinedOutput() | |||
| 96 | + | trimmed := strings.TrimSpace(string(out)) | |||
| 97 | + | if err != nil { | |||
| 98 | + | if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") { | |||
| 99 | + | return "", nil | |||
| 100 | + | } | |||
| 101 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |||
| 102 | + | return kind, nil | |||
| 103 | + | } | |||
| 104 | + | if path, ok := findClipboardImagePath(trimmed); ok { | |||
| 105 | + | return path, nil | |||
| 106 | + | } | |||
| 107 | + | if trimmed == "" { | |||
| 108 | + | return "", fmt.Errorf("osascript failed: %w", err) | |||
| 109 | + | } | |||
| 110 | + | return "", fmt.Errorf("osascript failed: %s", trimmed) | |||
| 111 | + | } | |||
| 112 | + | if trimmed == "NO_IMAGE" { | |||
| 113 | + | return "", nil | |||
| 114 | + | } | |||
| 115 | + | if kind, ok := clipboardImageKind(trimmed); ok { | |||
| 116 | + | return kind, nil | |||
| 117 | + | } | |||
| 118 | + | return trimmed, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func clipboardImageKind(s string) (string, bool) { | |||
| 122 | + | for _, line := range strings.Split(s, "\n") { | |||
| 123 | + | switch strings.TrimSpace(line) { | |||
| 124 | + | case "png": | |||
| 125 | + | return "png", true | |||
| 126 | + | case "tiff": | |||
| 127 | + | return "tiff", true | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return "", false | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | |||
| 134 | + | switch strings.ToLower(filepath.Ext(srcPath)) { | |||
| 135 | + | case ".png": | |||
| 136 | + | data, err := os.ReadFile(srcPath) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | return os.WriteFile(dstPath, data, 0o600) | |||
| 141 | + | case ".tif", ".tiff": | |||
| 142 | + | return convertTIFFFileToPNG(srcPath, dstPath) | |||
| 143 | + | default: | |||
| 144 | + | return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func findClipboardImagePath(s string) (string, bool) { | |||
| 149 | + | if p, ok := clipboardImagePath(s); ok { | |||
| 150 | + | return p, true | |||
| 151 | + | } | |||
| 152 | + | for _, ext := range []string{".png", ".tiff", ".tif"} { | |||
| 153 | + | lower := strings.ToLower(s) | |||
| 154 | + | end := strings.Index(lower, ext) | |||
| 155 | + | if end < 0 { | |||
| 156 | + | continue | |||
| 157 | + | } | |||
| 158 | + | end += len(ext) | |||
| 159 | + | start := strings.LastIndex(s[:end], "/") | |||
| 160 | + | if start < 0 { | |||
| 161 | + | continue | |||
| 162 | + | } | |||
| 163 | + | for start > 0 { | |||
| 164 | + | c := s[start-1] | |||
| 165 | + | if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' { | |||
| 166 | + | break | |||
| 167 | + | } | |||
| 168 | + | start-- | |||
| 169 | + | } | |||
| 170 | + | if p, ok := clipboardImagePath(s[start:end]); ok { | |||
| 171 | + | return p, true | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | return "", false | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func clipboardImagePath(s string) (string, bool) { | |||
| 178 | + | p := strings.TrimSpace(s) | |||
| 179 | + | p = strings.Trim(p, "'\"") | |||
| 180 | + | if p == "" { | |||
| 181 | + | return "", false | |||
| 182 | + | } | |||
| 183 | + | info, err := os.Stat(p) | |||
| 184 | + | if err != nil || info.IsDir() { | |||
| 185 | + | return "", false | |||
| 186 | + | } | |||
| 187 | + | switch strings.ToLower(filepath.Ext(p)) { | |||
| 188 | + | case ".png", ".tif", ".tiff": | |||
| 189 | + | return p, true | |||
| 190 | + | default: | |||
| 191 | + | return "", false | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func convertTIFFFileToPNG(srcPath, dstPath string) error { | |||
| 196 | + | in, err := os.Open(srcPath) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return err | |||
| 199 | + | } | |||
| 200 | + | defer in.Close() | |||
| 201 | + | ||||
| 202 | + | img, err := tiff.Decode(in) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return err | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | out, err := os.Create(dstPath) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | defer out.Close() | |||
| 212 | + | ||||
| 213 | + | return png.Encode(out, img) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func clipboardImageDir() string { | |||
| 217 | + | if info, err := os.Stat("/tmp"); err == nil && info.IsDir() { | |||
| 218 | + | return filepath.Join("/tmp", "zot-clipboard-images") | |||
| 219 | + | } | |||
| 220 | + | return filepath.Join(os.TempDir(), "zot-clipboard-images") | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func randomHex(n int) string { | |||
| 224 | + | buf := make([]byte, n) | |||
| 225 | + | if _, err := rand.Read(buf); err != nil { | |||
| 226 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 227 | + | } | |||
| 228 | + | return hex.EncodeToString(buf) | |||
| 229 | + | } | |||
A packages/tui/clipboard_stub.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | //go:build !darwin | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 6 | + | return "", nil, false, nil | |
| 7 | + | } | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | //go:build !darwin | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 6 | + | return "", nil, false, nil | |||
| 7 | + | } | |||
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 { | ||