Support text clipboard paste across inputs
- commit
- 34e0352d43e7529dbe5ab9eef95d63190f1c9108
- author
- patriceckhart · · 2026-07-25 15:12 UTC
- parent
- ee6f014
- signature
- unsigned
15 files · +324 -3
M README.md +1 -1
| @@ -846,7 +846,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||
| 846 | 846 | | `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. | | |
| 847 | 847 | | `ctrl+d` | Exit on empty input. | | |
| 848 | 848 | | `ctrl+l` | Redraw the screen. | | |
| 849 | - | | `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. | | |
| 849 | + | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | | |
| 850 | 850 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 851 | 851 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | |
| 852 | 852 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| @@ -846,7 +846,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||||
| 846 | | `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. | | 846 | | `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. | | ||
| 847 | | `ctrl+d` | Exit on empty input. | | 847 | | `ctrl+d` | Exit on empty input. | | ||
| 848 | | `ctrl+l` | Redraw the screen. | | 848 | | `ctrl+l` | Redraw the screen. | | ||
| 849 | - | | `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. | | 849 | + | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | |
| 850 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 850 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | ||
| 851 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | 851 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | ||
| 852 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 852 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
A packages/agent/modes/clipboard_inputs_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFilterInputsAcceptPaste(t *testing.T) { | |
| 10 | + | paste := tui.Key{Kind: tui.KeyPaste, Paste: " pasted\n query "} | |
| 11 | + | ||
| 12 | + | model := &modelDialog{} | |
| 13 | + | model.HandleKey(paste) | |
| 14 | + | if model.query != "pasted query" { | |
| 15 | + | t.Fatalf("model query = %q", model.query) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | jump := &jumpDialog{} | |
| 19 | + | jump.HandleKey(paste) | |
| 20 | + | if jump.filter != "pasted query" { | |
| 21 | + | t.Fatalf("jump filter = %q", jump.filter) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | rescue := &rescueDialog{} | |
| 25 | + | rescue.HandleKey(paste) | |
| 26 | + | if rescue.query != "pasted query" { | |
| 27 | + | t.Fatalf("rescue query = %q", rescue.query) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | login := &loginDialog{step: loginStepProvider} | |
| 31 | + | login.HandleKey(paste) | |
| 32 | + | if login.providerQuery != "pasted query" { | |
| 33 | + | t.Fatalf("provider query = %q", login.providerQuery) | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFilterInputsAcceptPaste(t *testing.T) { | |||
| 10 | + | paste := tui.Key{Kind: tui.KeyPaste, Paste: " pasted\n query "} | |||
| 11 | + | ||||
| 12 | + | model := &modelDialog{} | |||
| 13 | + | model.HandleKey(paste) | |||
| 14 | + | if model.query != "pasted query" { | |||
| 15 | + | t.Fatalf("model query = %q", model.query) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | jump := &jumpDialog{} | |||
| 19 | + | jump.HandleKey(paste) | |||
| 20 | + | if jump.filter != "pasted query" { | |||
| 21 | + | t.Fatalf("jump filter = %q", jump.filter) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | rescue := &rescueDialog{} | |||
| 25 | + | rescue.HandleKey(paste) | |||
| 26 | + | if rescue.query != "pasted query" { | |||
| 27 | + | t.Fatalf("rescue query = %q", rescue.query) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | login := &loginDialog{step: loginStepProvider} | |||
| 31 | + | login.HandleKey(paste) | |||
| 32 | + | if login.providerQuery != "pasted query" { | |||
| 33 | + | t.Fatalf("provider query = %q", login.providerQuery) | |||
| 34 | + | } | |||
| 35 | + | } | |||
A packages/agent/modes/clipboard_paste.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type clipboardTextReader func() (string, bool, error) | |
| 10 | + | ||
| 11 | + | // resolveClipboardText turns an application-level ctrl+v event into the same | |
| 12 | + | // KeyPaste event terminals produce for bracketed paste. Keeping that conversion | |
| 13 | + | // before dialog routing makes clipboard paste available to every text input. | |
| 14 | + | func resolveClipboardText(k tui.Key, read clipboardTextReader) (tui.Key, bool, error) { | |
| 15 | + | if k.Kind != tui.KeyPasteClipboard { | |
| 16 | + | return k, false, nil | |
| 17 | + | } | |
| 18 | + | text, ok, err := read() | |
| 19 | + | if err != nil || !ok || text == "" { | |
| 20 | + | return k, false, err | |
| 21 | + | } | |
| 22 | + | return tui.Key{Kind: tui.KeyPaste, Paste: text}, true, nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func singleLinePaste(text string) string { | |
| 26 | + | return strings.Join(strings.Fields(text), " ") | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type clipboardTextReader func() (string, bool, error) | |||
| 10 | + | ||||
| 11 | + | // resolveClipboardText turns an application-level ctrl+v event into the same | |||
| 12 | + | // KeyPaste event terminals produce for bracketed paste. Keeping that conversion | |||
| 13 | + | // before dialog routing makes clipboard paste available to every text input. | |||
| 14 | + | func resolveClipboardText(k tui.Key, read clipboardTextReader) (tui.Key, bool, error) { | |||
| 15 | + | if k.Kind != tui.KeyPasteClipboard { | |||
| 16 | + | return k, false, nil | |||
| 17 | + | } | |||
| 18 | + | text, ok, err := read() | |||
| 19 | + | if err != nil || !ok || text == "" { | |||
| 20 | + | return k, false, err | |||
| 21 | + | } | |||
| 22 | + | return tui.Key{Kind: tui.KeyPaste, Paste: text}, true, nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func singleLinePaste(text string) string { | |||
| 26 | + | return strings.Join(strings.Fields(text), " ") | |||
| 27 | + | } | |||
A packages/agent/modes/clipboard_paste_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolveClipboardTextConvertsClipboardKeyToPaste(t *testing.T) { | |
| 11 | + | key, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true}, func() (string, bool, error) { | |
| 12 | + | return "pasted\ntext", true, nil | |
| 13 | + | }) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | if !resolved || key.Kind != tui.KeyPaste || key.Paste != "pasted\ntext" { | |
| 18 | + | t.Fatalf("key = %#v, resolved = %v", key, resolved) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestResolveClipboardTextPreservesClipboardKeyWithoutText(t *testing.T) { | |
| 23 | + | original := tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true} | |
| 24 | + | key, resolved, err := resolveClipboardText(original, func() (string, bool, error) { | |
| 25 | + | return "", false, nil | |
| 26 | + | }) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if resolved || key != original { | |
| 31 | + | t.Fatalf("key = %#v, resolved = %v, want original unresolved key", key, resolved) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestResolveClipboardTextReturnsReadError(t *testing.T) { | |
| 36 | + | want := errors.New("clipboard unavailable") | |
| 37 | + | _, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) { | |
| 38 | + | return "", false, want | |
| 39 | + | }) | |
| 40 | + | if resolved || !errors.Is(err, want) { | |
| 41 | + | t.Fatalf("resolved = %v, err = %v, want %v", resolved, err, want) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestSingleLinePaste(t *testing.T) { | |
| 46 | + | if got := singleLinePaste(" claude\n opus\t5 "); got != "claude opus 5" { | |
| 47 | + | t.Fatalf("singleLinePaste = %q", got) | |
| 48 | + | } | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolveClipboardTextConvertsClipboardKeyToPaste(t *testing.T) { | |||
| 11 | + | key, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true}, func() (string, bool, error) { | |||
| 12 | + | return "pasted\ntext", true, nil | |||
| 13 | + | }) | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | if !resolved || key.Kind != tui.KeyPaste || key.Paste != "pasted\ntext" { | |||
| 18 | + | t.Fatalf("key = %#v, resolved = %v", key, resolved) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestResolveClipboardTextPreservesClipboardKeyWithoutText(t *testing.T) { | |||
| 23 | + | original := tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true} | |||
| 24 | + | key, resolved, err := resolveClipboardText(original, func() (string, bool, error) { | |||
| 25 | + | return "", false, nil | |||
| 26 | + | }) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if resolved || key != original { | |||
| 31 | + | t.Fatalf("key = %#v, resolved = %v, want original unresolved key", key, resolved) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestResolveClipboardTextReturnsReadError(t *testing.T) { | |||
| 36 | + | want := errors.New("clipboard unavailable") | |||
| 37 | + | _, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) { | |||
| 38 | + | return "", false, want | |||
| 39 | + | }) | |||
| 40 | + | if resolved || !errors.Is(err, want) { | |||
| 41 | + | t.Fatalf("resolved = %v, err = %v, want %v", resolved, err, want) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestSingleLinePaste(t *testing.T) { | |||
| 46 | + | if got := singleLinePaste(" claude\n opus\t5 "); got != "claude opus 5" { | |||
| 47 | + | t.Fatalf("singleLinePaste = %q", got) | |||
| 48 | + | } | |||
| 49 | + | } | |||
M packages/agent/modes/interactive.go +36 -2
| @@ -1933,6 +1933,28 @@ func (i *Interactive) restoreConfirmFocus() { | |||
| 1933 | 1933 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 1934 | 1934 | defer i.restoreConfirmFocus() | |
| 1935 | 1935 | ||
| 1936 | + | // Dialogs route keys before the main clipboard handler below. Resolve text | |
| 1937 | + | // here when a child interaction owns input so every editor and filter sees | |
| 1938 | + | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |
| 1939 | + | // left image-first to preserve macOS clipboard image attachments. | |
| 1940 | + | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |
| 1941 | + | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |
| 1942 | + | if err != nil { | |
| 1943 | + | i.mu.Lock() | |
| 1944 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 1945 | + | i.statusOK = "" | |
| 1946 | + | i.mu.Unlock() | |
| 1947 | + | return false | |
| 1948 | + | } | |
| 1949 | + | if pastedText { | |
| 1950 | + | k = resolved | |
| 1951 | + | i.mu.Lock() | |
| 1952 | + | i.statusErr = "" | |
| 1953 | + | i.statusOK = "" | |
| 1954 | + | i.mu.Unlock() | |
| 1955 | + | } | |
| 1956 | + | } | |
| 1957 | + | ||
| 1936 | 1958 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 1937 | 1959 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 1938 | 1960 | // unexpectedly. The hint message also goes stale; clear it. | |
| @@ -2645,10 +2667,22 @@ func (i *Interactive) pasteClipboard() { | |||
| 2645 | 2667 | i.mu.Unlock() | |
| 2646 | 2668 | return | |
| 2647 | 2669 | } | |
| 2670 | + | key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText) | |
| 2648 | 2671 | i.mu.Lock() | |
| 2649 | - | i.statusErr = "clipboard does not contain an image" | |
| 2672 | + | defer i.mu.Unlock() | |
| 2673 | + | if err != nil { | |
| 2674 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 2675 | + | i.statusOK = "" | |
| 2676 | + | return | |
| 2677 | + | } | |
| 2678 | + | if pastedText { | |
| 2679 | + | i.ed.HandleKey(key) | |
| 2680 | + | i.statusErr = "" | |
| 2681 | + | i.statusOK = "" | |
| 2682 | + | return | |
| 2683 | + | } | |
| 2684 | + | i.statusErr = "clipboard does not contain text or an image" | |
| 2650 | 2685 | i.statusOK = "" | |
| 2651 | - | i.mu.Unlock() | |
| 2652 | 2686 | } | |
| 2653 | 2687 | ||
| 2654 | 2688 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| @@ -1933,6 +1933,28 @@ func (i *Interactive) restoreConfirmFocus() { | |||||
| 1933 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | 1933 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | ||
| 1934 | defer i.restoreConfirmFocus() | 1934 | defer i.restoreConfirmFocus() | ||
| 1935 | 1935 | ||||
| 1936 | + | // Dialogs route keys before the main clipboard handler below. Resolve text | |||
| 1937 | + | // here when a child interaction owns input so every editor and filter sees | |||
| 1938 | + | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |||
| 1939 | + | // left image-first to preserve macOS clipboard image attachments. | |||
| 1940 | + | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |||
| 1941 | + | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |||
| 1942 | + | if err != nil { | |||
| 1943 | + | i.mu.Lock() | |||
| 1944 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |||
| 1945 | + | i.statusOK = "" | |||
| 1946 | + | i.mu.Unlock() | |||
| 1947 | + | return false | |||
| 1948 | + | } | |||
| 1949 | + | if pastedText { | |||
| 1950 | + | k = resolved | |||
| 1951 | + | i.mu.Lock() | |||
| 1952 | + | i.statusErr = "" | |||
| 1953 | + | i.statusOK = "" | |||
| 1954 | + | i.mu.Unlock() | |||
| 1955 | + | } | |||
| 1956 | + | } | |||
| 1957 | + | ||||
| 1936 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | 1958 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | ||
| 1937 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | 1959 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | ||
| 1938 | // unexpectedly. The hint message also goes stale; clear it. | 1960 | // unexpectedly. The hint message also goes stale; clear it. | ||
| @@ -2645,10 +2667,22 @@ func (i *Interactive) pasteClipboard() { | |||||
| 2645 | i.mu.Unlock() | 2667 | i.mu.Unlock() | ||
| 2646 | return | 2668 | return | ||
| 2647 | } | 2669 | } | ||
| 2670 | + | key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText) | |||
| 2648 | i.mu.Lock() | 2671 | i.mu.Lock() | ||
| 2649 | - | i.statusErr = "clipboard does not contain an image" | 2672 | + | defer i.mu.Unlock() |
| 2673 | + | if err != nil { | |||
| 2674 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |||
| 2675 | + | i.statusOK = "" | |||
| 2676 | + | return | |||
| 2677 | + | } | |||
| 2678 | + | if pastedText { | |||
| 2679 | + | i.ed.HandleKey(key) | |||
| 2680 | + | i.statusErr = "" | |||
| 2681 | + | i.statusOK = "" | |||
| 2682 | + | return | |||
| 2683 | + | } | |||
| 2684 | + | i.statusErr = "clipboard does not contain text or an image" | |||
| 2650 | i.statusOK = "" | 2685 | i.statusOK = "" | ||
| 2651 | - | i.mu.Unlock() | |||
| 2652 | } | 2686 | } | ||
| 2653 | 2687 | ||||
| 2654 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | 2688 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | ||
M packages/agent/modes/jump_dialog.go +3 -0
| @@ -216,6 +216,9 @@ func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction { | |||
| 216 | 216 | d.filter = string(runes[:len(runes)-1]) | |
| 217 | 217 | d.applyFilter() | |
| 218 | 218 | } | |
| 219 | + | case tui.KeyPaste: | |
| 220 | + | d.filter += singleLinePaste(k.Paste) | |
| 221 | + | d.applyFilter() | |
| 219 | 222 | case tui.KeyRune: | |
| 220 | 223 | // Any printable rune extends the filter. | |
| 221 | 224 | d.filter += string(k.Rune) | |
| @@ -216,6 +216,9 @@ func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction { | |||||
| 216 | d.filter = string(runes[:len(runes)-1]) | 216 | d.filter = string(runes[:len(runes)-1]) | ||
| 217 | d.applyFilter() | 217 | d.applyFilter() | ||
| 218 | } | 218 | } | ||
| 219 | + | case tui.KeyPaste: | |||
| 220 | + | d.filter += singleLinePaste(k.Paste) | |||
| 221 | + | d.applyFilter() | |||
| 219 | case tui.KeyRune: | 222 | case tui.KeyRune: | ||
| 220 | // Any printable rune extends the filter. | 223 | // Any printable rune extends the filter. | ||
| 221 | d.filter += string(k.Rune) | 224 | d.filter += string(k.Rune) | ||
M packages/agent/modes/login_dialog.go +3 -0
| @@ -520,6 +520,9 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 520 | 520 | d.providerQuery = string(runes[:len(runes)-1]) | |
| 521 | 521 | d.cursor = 0 | |
| 522 | 522 | } | |
| 523 | + | case tui.KeyPaste: | |
| 524 | + | d.providerQuery += singleLinePaste(k.Paste) | |
| 525 | + | d.cursor = 0 | |
| 523 | 526 | case tui.KeyRune: | |
| 524 | 527 | if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f { | |
| 525 | 528 | d.providerQuery += string(k.Rune) | |
| @@ -520,6 +520,9 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 520 | d.providerQuery = string(runes[:len(runes)-1]) | 520 | d.providerQuery = string(runes[:len(runes)-1]) | ||
| 521 | d.cursor = 0 | 521 | d.cursor = 0 | ||
| 522 | } | 522 | } | ||
| 523 | + | case tui.KeyPaste: | |||
| 524 | + | d.providerQuery += singleLinePaste(k.Paste) | |||
| 525 | + | d.cursor = 0 | |||
| 523 | case tui.KeyRune: | 526 | case tui.KeyRune: | ||
| 524 | if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f { | 527 | if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f { | ||
| 525 | d.providerQuery += string(k.Rune) | 528 | d.providerQuery += string(k.Rune) | ||
M packages/agent/modes/model_dialog.go +3 -0
| @@ -284,6 +284,9 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |||
| 284 | 284 | d.query = string(r[:len(r)-1]) | |
| 285 | 285 | d.refilter() | |
| 286 | 286 | } | |
| 287 | + | case tui.KeyPaste: | |
| 288 | + | d.query += singleLinePaste(k.Paste) | |
| 289 | + | d.refilter() | |
| 287 | 290 | case tui.KeyRune: | |
| 288 | 291 | if k.Alt || k.Ctrl { | |
| 289 | 292 | break | |
| @@ -284,6 +284,9 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |||||
| 284 | d.query = string(r[:len(r)-1]) | 284 | d.query = string(r[:len(r)-1]) | ||
| 285 | d.refilter() | 285 | d.refilter() | ||
| 286 | } | 286 | } | ||
| 287 | + | case tui.KeyPaste: | |||
| 288 | + | d.query += singleLinePaste(k.Paste) | |||
| 289 | + | d.refilter() | |||
| 287 | case tui.KeyRune: | 290 | case tui.KeyRune: | ||
| 288 | if k.Alt || k.Ctrl { | 291 | if k.Alt || k.Ctrl { | ||
| 289 | break | 292 | break | ||
M packages/agent/modes/rescue_dialog.go +3 -0
| @@ -191,6 +191,9 @@ func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction { | |||
| 191 | 191 | d.query = string(r[:len(r)-1]) | |
| 192 | 192 | d.refilter() | |
| 193 | 193 | } | |
| 194 | + | case tui.KeyPaste: | |
| 195 | + | d.query += singleLinePaste(k.Paste) | |
| 196 | + | d.refilter() | |
| 194 | 197 | case tui.KeyRune: | |
| 195 | 198 | if k.Alt || k.Ctrl { | |
| 196 | 199 | break | |
| @@ -191,6 +191,9 @@ func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction { | |||||
| 191 | d.query = string(r[:len(r)-1]) | 191 | d.query = string(r[:len(r)-1]) | ||
| 192 | d.refilter() | 192 | d.refilter() | ||
| 193 | } | 193 | } | ||
| 194 | + | case tui.KeyPaste: | |||
| 195 | + | d.query += singleLinePaste(k.Paste) | |||
| 196 | + | d.refilter() | |||
| 194 | case tui.KeyRune: | 197 | case tui.KeyRune: | ||
| 195 | if k.Alt || k.Ctrl { | 198 | if k.Alt || k.Ctrl { | ||
| 196 | break | 199 | break | ||
A packages/tui/clipboard_text_command.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os/exec" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type clipboardTextCommand struct { | |
| 11 | + | name string | |
| 12 | + | args []string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | var errClipboardCommandUnavailable = errors.New("no supported clipboard command found") | |
| 16 | + | ||
| 17 | + | // readClipboardTextCommands tries each installed command in order. Command | |
| 18 | + | // stdout is the clipboard payload and is never included in returned errors. | |
| 19 | + | func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool, error) { | |
| 20 | + | var lastErr error | |
| 21 | + | found := false | |
| 22 | + | for _, candidate := range commands { | |
| 23 | + | path, err := exec.LookPath(candidate.name) | |
| 24 | + | if err != nil { | |
| 25 | + | continue | |
| 26 | + | } | |
| 27 | + | found = true | |
| 28 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 29 | + | out, err := exec.CommandContext(ctx, path, candidate.args...).Output() | |
| 30 | + | cancel() | |
| 31 | + | if err != nil { | |
| 32 | + | lastErr = err | |
| 33 | + | continue | |
| 34 | + | } | |
| 35 | + | if len(out) == 0 { | |
| 36 | + | return "", false, nil | |
| 37 | + | } | |
| 38 | + | return string(out), true, nil | |
| 39 | + | } | |
| 40 | + | if !found { | |
| 41 | + | return "", false, errClipboardCommandUnavailable | |
| 42 | + | } | |
| 43 | + | return "", false, lastErr | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type clipboardTextCommand struct { | |||
| 11 | + | name string | |||
| 12 | + | args []string | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | var errClipboardCommandUnavailable = errors.New("no supported clipboard command found") | |||
| 16 | + | ||||
| 17 | + | // readClipboardTextCommands tries each installed command in order. Command | |||
| 18 | + | // stdout is the clipboard payload and is never included in returned errors. | |||
| 19 | + | func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool, error) { | |||
| 20 | + | var lastErr error | |||
| 21 | + | found := false | |||
| 22 | + | for _, candidate := range commands { | |||
| 23 | + | path, err := exec.LookPath(candidate.name) | |||
| 24 | + | if err != nil { | |||
| 25 | + | continue | |||
| 26 | + | } | |||
| 27 | + | found = true | |||
| 28 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 29 | + | out, err := exec.CommandContext(ctx, path, candidate.args...).Output() | |||
| 30 | + | cancel() | |||
| 31 | + | if err != nil { | |||
| 32 | + | lastErr = err | |||
| 33 | + | continue | |||
| 34 | + | } | |||
| 35 | + | if len(out) == 0 { | |||
| 36 | + | return "", false, nil | |||
| 37 | + | } | |||
| 38 | + | return string(out), true, nil | |||
| 39 | + | } | |||
| 40 | + | if !found { | |||
| 41 | + | return "", false, errClipboardCommandUnavailable | |||
| 42 | + | } | |||
| 43 | + | return "", false, lastErr | |||
| 44 | + | } | |||
A packages/tui/clipboard_text_command_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "runtime" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestReadClipboardTextCommandsUsesAvailableCommand(t *testing.T) { | |
| 11 | + | if runtime.GOOS == "windows" { | |
| 12 | + | t.Skip("test helper is a shell script") | |
| 13 | + | } | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | path := filepath.Join(dir, "clipboard-test") | |
| 16 | + | if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'clipboard text'\n"), 0o700); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | t.Setenv("PATH", dir) | |
| 20 | + | ||
| 21 | + | text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "clipboard-test"}) | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | if !ok || text != "clipboard text" { | |
| 26 | + | t.Fatalf("text = %q, ok = %v", text, ok) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) { | |
| 31 | + | t.Setenv("PATH", t.TempDir()) | |
| 32 | + | _, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "missing-clipboard-command"}) | |
| 33 | + | if ok || err != errClipboardCommandUnavailable { | |
| 34 | + | t.Fatalf("ok = %v, err = %v", ok, err) | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "runtime" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestReadClipboardTextCommandsUsesAvailableCommand(t *testing.T) { | |||
| 11 | + | if runtime.GOOS == "windows" { | |||
| 12 | + | t.Skip("test helper is a shell script") | |||
| 13 | + | } | |||
| 14 | + | dir := t.TempDir() | |||
| 15 | + | path := filepath.Join(dir, "clipboard-test") | |||
| 16 | + | if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'clipboard text'\n"), 0o700); err != nil { | |||
| 17 | + | t.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | t.Setenv("PATH", dir) | |||
| 20 | + | ||||
| 21 | + | text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "clipboard-test"}) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | if !ok || text != "clipboard text" { | |||
| 26 | + | t.Fatalf("text = %q, ok = %v", text, ok) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) { | |||
| 31 | + | t.Setenv("PATH", t.TempDir()) | |||
| 32 | + | _, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "missing-clipboard-command"}) | |||
| 33 | + | if ok || err != errClipboardCommandUnavailable { | |||
| 34 | + | t.Fatalf("ok = %v, err = %v", ok, err) | |||
| 35 | + | } | |||
| 36 | + | } | |||
A packages/tui/clipboard_text_darwin.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // ReadClipboardText reads plain text from the macOS system clipboard. | |
| 8 | + | func ReadClipboardText() (string, bool, error) { | |
| 9 | + | text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "/usr/bin/pbpaste"}) | |
| 10 | + | if err == errClipboardCommandUnavailable { | |
| 11 | + | return "", false, fmt.Errorf("text clipboard unavailable: pbpaste was not found") | |
| 12 | + | } | |
| 13 | + | return text, ok, err | |
| 14 | + | } | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | //go:build darwin | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | // ReadClipboardText reads plain text from the macOS system clipboard. | |||
| 8 | + | func ReadClipboardText() (string, bool, error) { | |||
| 9 | + | text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "/usr/bin/pbpaste"}) | |||
| 10 | + | if err == errClipboardCommandUnavailable { | |||
| 11 | + | return "", false, fmt.Errorf("text clipboard unavailable: pbpaste was not found") | |||
| 12 | + | } | |||
| 13 | + | return text, ok, err | |||
| 14 | + | } | |||
A packages/tui/clipboard_text_linux.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ReadClipboardText reads plain text from a Wayland or X11 clipboard. The | |
| 11 | + | // small command-line clients are used to preserve zot's CGO-free build. | |
| 12 | + | func ReadClipboardText() (string, bool, error) { | |
| 13 | + | var commands []clipboardTextCommand | |
| 14 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 15 | + | commands = append(commands, clipboardTextCommand{name: "wl-paste", args: []string{"--no-newline", "--type", "text"}}) | |
| 16 | + | } | |
| 17 | + | if os.Getenv("DISPLAY") != "" { | |
| 18 | + | commands = append(commands, | |
| 19 | + | clipboardTextCommand{name: "xclip", args: []string{"-selection", "clipboard", "-out"}}, | |
| 20 | + | clipboardTextCommand{name: "xsel", args: []string{"--clipboard", "--output"}}, | |
| 21 | + | ) | |
| 22 | + | } | |
| 23 | + | // Environment forwarding through multiplexers is not always reliable. | |
| 24 | + | // Try every backend if no display variable made a preferred list. | |
| 25 | + | if len(commands) == 0 { | |
| 26 | + | commands = []clipboardTextCommand{ | |
| 27 | + | {name: "wl-paste", args: []string{"--no-newline", "--type", "text"}}, | |
| 28 | + | {name: "xclip", args: []string{"-selection", "clipboard", "-out"}}, | |
| 29 | + | {name: "xsel", args: []string{"--clipboard", "--output"}}, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | text, ok, err := readClipboardTextCommands(commands...) | |
| 33 | + | if err == errClipboardCommandUnavailable { | |
| 34 | + | return "", false, fmt.Errorf("text clipboard unavailable: install wl-clipboard, xclip, or xsel, or use the terminal paste shortcut") | |
| 35 | + | } | |
| 36 | + | if err != nil { | |
| 37 | + | return "", false, fmt.Errorf("read text clipboard: %w", err) | |
| 38 | + | } | |
| 39 | + | return text, ok, nil | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ReadClipboardText reads plain text from a Wayland or X11 clipboard. The | |||
| 11 | + | // small command-line clients are used to preserve zot's CGO-free build. | |||
| 12 | + | func ReadClipboardText() (string, bool, error) { | |||
| 13 | + | var commands []clipboardTextCommand | |||
| 14 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |||
| 15 | + | commands = append(commands, clipboardTextCommand{name: "wl-paste", args: []string{"--no-newline", "--type", "text"}}) | |||
| 16 | + | } | |||
| 17 | + | if os.Getenv("DISPLAY") != "" { | |||
| 18 | + | commands = append(commands, | |||
| 19 | + | clipboardTextCommand{name: "xclip", args: []string{"-selection", "clipboard", "-out"}}, | |||
| 20 | + | clipboardTextCommand{name: "xsel", args: []string{"--clipboard", "--output"}}, | |||
| 21 | + | ) | |||
| 22 | + | } | |||
| 23 | + | // Environment forwarding through multiplexers is not always reliable. | |||
| 24 | + | // Try every backend if no display variable made a preferred list. | |||
| 25 | + | if len(commands) == 0 { | |||
| 26 | + | commands = []clipboardTextCommand{ | |||
| 27 | + | {name: "wl-paste", args: []string{"--no-newline", "--type", "text"}}, | |||
| 28 | + | {name: "xclip", args: []string{"-selection", "clipboard", "-out"}}, | |||
| 29 | + | {name: "xsel", args: []string{"--clipboard", "--output"}}, | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | text, ok, err := readClipboardTextCommands(commands...) | |||
| 33 | + | if err == errClipboardCommandUnavailable { | |||
| 34 | + | return "", false, fmt.Errorf("text clipboard unavailable: install wl-clipboard, xclip, or xsel, or use the terminal paste shortcut") | |||
| 35 | + | } | |||
| 36 | + | if err != nil { | |||
| 37 | + | return "", false, fmt.Errorf("read text clipboard: %w", err) | |||
| 38 | + | } | |||
| 39 | + | return text, ok, nil | |||
| 40 | + | } | |||
A packages/tui/clipboard_text_stub.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | //go:build !darwin && !linux && !windows | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | func ReadClipboardText() (string, bool, error) { | |
| 8 | + | return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut") | |
| 9 | + | } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | //go:build !darwin && !linux && !windows | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | func ReadClipboardText() (string, bool, error) { | |||
| 8 | + | return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut") | |||
| 9 | + | } | |||
A packages/tui/clipboard_text_windows.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // ReadClipboardText reads plain text through the PowerShell installation | |
| 8 | + | // included with supported Windows versions. | |
| 9 | + | func ReadClipboardText() (string, bool, error) { | |
| 10 | + | text, ok, err := readClipboardTextCommands( | |
| 11 | + | clipboardTextCommand{name: "powershell.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw -Format Text"}}, | |
| 12 | + | clipboardTextCommand{name: "pwsh.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"}}, | |
| 13 | + | ) | |
| 14 | + | if err == errClipboardCommandUnavailable { | |
| 15 | + | return "", false, fmt.Errorf("text clipboard unavailable: PowerShell was not found") | |
| 16 | + | } | |
| 17 | + | if err != nil { | |
| 18 | + | return "", false, fmt.Errorf("read text clipboard: %w", err) | |
| 19 | + | } | |
| 20 | + | return text, ok, nil | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | // ReadClipboardText reads plain text through the PowerShell installation | |||
| 8 | + | // included with supported Windows versions. | |||
| 9 | + | func ReadClipboardText() (string, bool, error) { | |||
| 10 | + | text, ok, err := readClipboardTextCommands( | |||
| 11 | + | clipboardTextCommand{name: "powershell.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw -Format Text"}}, | |||
| 12 | + | clipboardTextCommand{name: "pwsh.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"}}, | |||
| 13 | + | ) | |||
| 14 | + | if err == errClipboardCommandUnavailable { | |||
| 15 | + | return "", false, fmt.Errorf("text clipboard unavailable: PowerShell was not found") | |||
| 16 | + | } | |||
| 17 | + | if err != nil { | |||
| 18 | + | return "", false, fmt.Errorf("read text clipboard: %w", err) | |||
| 19 | + | } | |||
| 20 | + | return text, ok, nil | |||
| 21 | + | } | |||