feat(login): copy OAuth URL from dialog (#165)
* feat(login): copy OAuth URL from dialog * fix(login): address clipboard review findings * fix(login): gate OSC 52 clipboard writes
- commit
- d8a14e75cfb640e24922869f5f711e4ca92eb60a
- author
- bnema · · 2026-09-01 14:57 UTC
- committer
- GitHub · · 2026-09-01 14:57 UTC
- parent
- 131edba
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
15 files · +536 -6
M packages/agent/modes/interactive_input_routing.go +3 -0
| @@ -150,6 +150,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 150 | 150 | if act.SaveLlama { | |
| 151 | 151 | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 152 | 152 | } | |
| 153 | + | if act.CopyURL { | |
| 154 | + | i.copyLoginURL(ctx) | |
| 155 | + | } | |
| 153 | 156 | return false | |
| 154 | 157 | } | |
| 155 | 158 | if i.modelDialog.Active() { | |
| @@ -150,6 +150,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 150 | if act.SaveLlama { | 150 | if act.SaveLlama { | ||
| 151 | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | 151 | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | ||
| 152 | } | 152 | } | ||
| 153 | + | if act.CopyURL { | |||
| 154 | + | i.copyLoginURL(ctx) | |||
| 155 | + | } | |||
| 153 | return false | 156 | return false | ||
| 154 | } | 157 | } | ||
| 155 | if i.modelDialog.Active() { | 158 | if i.modelDialog.Active() { | ||
A packages/agent/modes/interactive_login_clipboard.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // copyLoginURL copies the manual OAuth URL without disturbing the code editor. | |
| 10 | + | // OSC 52 is preferred so an SSH client can copy to the user's local terminal; | |
| 11 | + | // a process-local system clipboard is the fallback when terminal output fails. | |
| 12 | + | func (i *Interactive) copyLoginURL(ctx context.Context) { | |
| 13 | + | i.mu.Lock() | |
| 14 | + | url := i.dialog.url | |
| 15 | + | term := i.cfg.Terminal | |
| 16 | + | i.mu.Unlock() | |
| 17 | + | ||
| 18 | + | if term != nil && tui.SupportsOSC52Clipboard() && tui.WriteClipboardTextOSC52(term, url) == nil { | |
| 19 | + | i.mu.Lock() | |
| 20 | + | i.statusErr = "" | |
| 21 | + | i.statusOK = "sent login URL to terminal clipboard" | |
| 22 | + | i.mu.Unlock() | |
| 23 | + | i.invalidate() | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | err := tui.WriteClipboardText(ctx, url) | |
| 28 | + | if err == nil { | |
| 29 | + | i.mu.Lock() | |
| 30 | + | i.statusErr = "" | |
| 31 | + | i.statusOK = "login URL copied" | |
| 32 | + | i.mu.Unlock() | |
| 33 | + | i.invalidate() | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | i.mu.Lock() | |
| 38 | + | i.statusErr = "copy login URL: " + err.Error() | |
| 39 | + | i.statusOK = "" | |
| 40 | + | i.mu.Unlock() | |
| 41 | + | i.invalidate() | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // copyLoginURL copies the manual OAuth URL without disturbing the code editor. | |||
| 10 | + | // OSC 52 is preferred so an SSH client can copy to the user's local terminal; | |||
| 11 | + | // a process-local system clipboard is the fallback when terminal output fails. | |||
| 12 | + | func (i *Interactive) copyLoginURL(ctx context.Context) { | |||
| 13 | + | i.mu.Lock() | |||
| 14 | + | url := i.dialog.url | |||
| 15 | + | term := i.cfg.Terminal | |||
| 16 | + | i.mu.Unlock() | |||
| 17 | + | ||||
| 18 | + | if term != nil && tui.SupportsOSC52Clipboard() && tui.WriteClipboardTextOSC52(term, url) == nil { | |||
| 19 | + | i.mu.Lock() | |||
| 20 | + | i.statusErr = "" | |||
| 21 | + | i.statusOK = "sent login URL to terminal clipboard" | |||
| 22 | + | i.mu.Unlock() | |||
| 23 | + | i.invalidate() | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | err := tui.WriteClipboardText(ctx, url) | |||
| 28 | + | if err == nil { | |||
| 29 | + | i.mu.Lock() | |||
| 30 | + | i.statusErr = "" | |||
| 31 | + | i.statusOK = "login URL copied" | |||
| 32 | + | i.mu.Unlock() | |||
| 33 | + | i.invalidate() | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | i.mu.Lock() | |||
| 38 | + | i.statusErr = "copy login URL: " + err.Error() | |||
| 39 | + | i.statusOK = "" | |||
| 40 | + | i.mu.Unlock() | |||
| 41 | + | i.invalidate() | |||
| 42 | + | } | |||
M packages/agent/modes/login_dialog.go +10 -3
| @@ -213,7 +213,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 213 | 213 | } | |
| 214 | 214 | lines = append(lines, "") | |
| 215 | 215 | if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" { | |
| 216 | - | lines = append(lines, th.FGColor(th.Muted, "complete sign-in in the browser - esc cancels")) | |
| 216 | + | lines = append(lines, th.FGColor(th.Muted, "alt+c copies URL - complete sign-in in the browser - esc cancels")) | |
| 217 | 217 | lines = append(lines, frameRule(th, width)) | |
| 218 | 218 | break | |
| 219 | 219 | } | |
| @@ -224,7 +224,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 224 | 224 | edLines, _, _ := d.codeEd.Render(width - 2) | |
| 225 | 225 | lines = append(lines, edLines...) | |
| 226 | 226 | lines = append(lines, "") | |
| 227 | - | lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels - waiting for browser callback in background")) | |
| 227 | + | lines = append(lines, th.FGColor(th.Muted, "alt+c copies URL - enter submits - esc cancels - waiting for browser callback in background")) | |
| 228 | 228 | lines = append(lines, frameRule(th, width)) | |
| 229 | 229 | case loginStepPasteCode: | |
| 230 | 230 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width)) | |
| @@ -244,7 +244,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 244 | 244 | edLines, _, _ := d.codeEd.Render(width - 2) | |
| 245 | 245 | lines = append(lines, edLines...) | |
| 246 | 246 | lines = append(lines, "") | |
| 247 | - | lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels")) | |
| 247 | + | lines = append(lines, th.FGColor(th.Muted, "alt+c copies URL - enter submits - esc cancels")) | |
| 248 | 248 | lines = append(lines, frameRule(th, width)) | |
| 249 | 249 | case loginStepInfo: | |
| 250 | 250 | title := d.infoTitle | |
| @@ -428,6 +428,7 @@ type loginDialogAction struct { | |||
| 428 | 428 | LlamaAPIKey string | |
| 429 | 429 | Close bool | |
| 430 | 430 | SubmitCode string | |
| 431 | + | CopyURL bool | |
| 431 | 432 | } | |
| 432 | 433 | ||
| 433 | 434 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| @@ -610,6 +611,9 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||
| 610 | 611 | d.Close() | |
| 611 | 612 | return loginDialogAction{Close: true} | |
| 612 | 613 | } | |
| 614 | + | if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') { | |
| 615 | + | return loginDialogAction{CopyURL: true} | |
| 616 | + | } | |
| 613 | 617 | if d.codeEd == nil { | |
| 614 | 618 | return loginDialogAction{} | |
| 615 | 619 | } | |
| @@ -626,6 +630,9 @@ func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { | |||
| 626 | 630 | d.Close() | |
| 627 | 631 | return loginDialogAction{Close: true} | |
| 628 | 632 | } | |
| 633 | + | if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') { | |
| 634 | + | return loginDialogAction{CopyURL: true} | |
| 635 | + | } | |
| 629 | 636 | if d.codeEd == nil { | |
| 630 | 637 | return loginDialogAction{} | |
| 631 | 638 | } | |
| @@ -213,7 +213,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 213 | } | 213 | } | ||
| 214 | lines = append(lines, "") | 214 | lines = append(lines, "") | ||
| 215 | if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" { | 215 | if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" { | ||
| 216 | - | lines = append(lines, th.FGColor(th.Muted, "complete sign-in in the browser - esc cancels")) | 216 | + | lines = append(lines, th.FGColor(th.Muted, "alt+c copies URL - complete sign-in in the browser - esc cancels")) |
| 217 | lines = append(lines, frameRule(th, width)) | 217 | lines = append(lines, frameRule(th, width)) | ||
| 218 | break | 218 | break | ||
| 219 | } | 219 | } | ||
| @@ -224,7 +224,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 224 | edLines, _, _ := d.codeEd.Render(width - 2) | 224 | edLines, _, _ := d.codeEd.Render(width - 2) | ||
| 225 | lines = append(lines, edLines...) | 225 | lines = append(lines, edLines...) | ||
| 226 | lines = append(lines, "") | 226 | lines = append(lines, "") | ||
| 227 | - | lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels - waiting for browser callback in background")) | 227 | + | lines = append(lines, th.FGColor(th.Muted, "alt+c copies URL - enter submits - esc cancels - waiting for browser callback in background")) |
| 228 | lines = append(lines, frameRule(th, width)) | 228 | lines = append(lines, frameRule(th, width)) | ||
| 229 | case loginStepPasteCode: | 229 | case loginStepPasteCode: | ||
| 230 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width)) | 230 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider)+" - paste code", width)) | ||
| @@ -244,7 +244,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 244 | edLines, _, _ := d.codeEd.Render(width - 2) | 244 | edLines, _, _ := d.codeEd.Render(width - 2) | ||
| 245 | lines = append(lines, edLines...) | 245 | lines = append(lines, edLines...) | ||
| 246 | lines = append(lines, "") | 246 | lines = append(lines, "") | ||
| 247 | - | lines = append(lines, th.FGColor(th.Muted, "enter submits - esc cancels")) | 247 | + | lines = append(lines, th.FGColor(th.Muted, "alt+c copies URL - enter submits - esc cancels")) |
| 248 | lines = append(lines, frameRule(th, width)) | 248 | lines = append(lines, frameRule(th, width)) | ||
| 249 | case loginStepInfo: | 249 | case loginStepInfo: | ||
| 250 | title := d.infoTitle | 250 | title := d.infoTitle | ||
| @@ -428,6 +428,7 @@ type loginDialogAction struct { | |||||
| 428 | LlamaAPIKey string | 428 | LlamaAPIKey string | ||
| 429 | Close bool | 429 | Close bool | ||
| 430 | SubmitCode string | 430 | SubmitCode string | ||
| 431 | + | CopyURL bool | |||
| 431 | } | 432 | } | ||
| 432 | 433 | ||||
| 433 | // HandleKey advances the dialog and returns an action to apply, if any. | 434 | // HandleKey advances the dialog and returns an action to apply, if any. | ||
| @@ -610,6 +611,9 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||||
| 610 | d.Close() | 611 | d.Close() | ||
| 611 | return loginDialogAction{Close: true} | 612 | return loginDialogAction{Close: true} | ||
| 612 | } | 613 | } | ||
| 614 | + | if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') { | |||
| 615 | + | return loginDialogAction{CopyURL: true} | |||
| 616 | + | } | |||
| 613 | if d.codeEd == nil { | 617 | if d.codeEd == nil { | ||
| 614 | return loginDialogAction{} | 618 | return loginDialogAction{} | ||
| 615 | } | 619 | } | ||
| @@ -626,6 +630,9 @@ func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { | |||||
| 626 | d.Close() | 630 | d.Close() | ||
| 627 | return loginDialogAction{Close: true} | 631 | return loginDialogAction{Close: true} | ||
| 628 | } | 632 | } | ||
| 633 | + | if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') { | |||
| 634 | + | return loginDialogAction{CopyURL: true} | |||
| 635 | + | } | |||
| 629 | if d.codeEd == nil { | 636 | if d.codeEd == nil { | ||
| 630 | return loginDialogAction{} | 637 | return loginDialogAction{} | ||
| 631 | } | 638 | } | ||
A packages/agent/modes/login_dialog_linux_test.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package modes | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/tui" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type blockingLoginClipboardTerminal struct { | |
| 18 | + | alertTestTerminal | |
| 19 | + | started chan struct{} | |
| 20 | + | release chan struct{} | |
| 21 | + | once sync.Once | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (t *blockingLoginClipboardTerminal) Write(p []byte) (int, error) { | |
| 25 | + | t.once.Do(func() { close(t.started) }) | |
| 26 | + | <-t.release | |
| 27 | + | return t.alertTestTerminal.Write(p) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (t *blockingLoginClipboardTerminal) WriteString(s string) (int, error) { | |
| 31 | + | return t.Write([]byte(s)) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestInteractiveAltCUsesSystemClipboardWithoutTerminal(t *testing.T) { | |
| 35 | + | dir := t.TempDir() | |
| 36 | + | payload := filepath.Join(dir, "clipboard-payload") | |
| 37 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | t.Setenv("PATH", dir) | |
| 41 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 42 | + | t.Setenv("DISPLAY", "") | |
| 43 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |
| 44 | + | ||
| 45 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |
| 46 | + | i := NewInteractive(InteractiveConfig{}) | |
| 47 | + | i.dialog.Open(t.TempDir()) | |
| 48 | + | i.dialog.ShowWaiting(loginURL) | |
| 49 | + | ||
| 50 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |
| 51 | + | ||
| 52 | + | got, err := os.ReadFile(payload) | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | if string(got) != loginURL { | |
| 57 | + | t.Fatalf("clipboard payload = %q, want %q", got, loginURL) | |
| 58 | + | } | |
| 59 | + | i.mu.Lock() | |
| 60 | + | defer i.mu.Unlock() | |
| 61 | + | if i.statusOK != "login URL copied" || i.statusErr != "" { | |
| 62 | + | t.Fatalf("status = (%q, %q), want successful system copy", i.statusOK, i.statusErr) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestInteractiveAltCReportsSystemClipboardError(t *testing.T) { | |
| 67 | + | dir := t.TempDir() | |
| 68 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | t.Setenv("PATH", dir) | |
| 72 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 73 | + | t.Setenv("DISPLAY", "") | |
| 74 | + | ||
| 75 | + | i := NewInteractive(InteractiveConfig{}) | |
| 76 | + | i.dialog.Open(t.TempDir()) | |
| 77 | + | i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz") | |
| 78 | + | ||
| 79 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |
| 80 | + | ||
| 81 | + | i.mu.Lock() | |
| 82 | + | defer i.mu.Unlock() | |
| 83 | + | if !strings.Contains(i.statusErr, "exit status 23") { | |
| 84 | + | t.Fatalf("status error = %q, want clipboard command failure", i.statusErr) | |
| 85 | + | } | |
| 86 | + | if i.statusOK != "" { | |
| 87 | + | t.Fatalf("success status = %q, want empty", i.statusOK) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestInteractiveAltCUsesSystemClipboardWhenOSC52IsUnknown(t *testing.T) { | |
| 92 | + | dir := t.TempDir() | |
| 93 | + | payload := filepath.Join(dir, "clipboard-payload") | |
| 94 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | t.Setenv("PATH", dir) | |
| 98 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 99 | + | t.Setenv("DISPLAY", "") | |
| 100 | + | t.Setenv("TERM_PROGRAM", "") | |
| 101 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |
| 102 | + | ||
| 103 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |
| 104 | + | term := &alertTestTerminal{} | |
| 105 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |
| 106 | + | i.dialog.Open(t.TempDir()) | |
| 107 | + | i.dialog.ShowWaiting(loginURL) | |
| 108 | + | ||
| 109 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |
| 110 | + | ||
| 111 | + | if got := term.String(); got != "" { | |
| 112 | + | t.Fatalf("terminal output = %q, want no OSC 52 request", got) | |
| 113 | + | } | |
| 114 | + | got, err := os.ReadFile(payload) | |
| 115 | + | if err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | if string(got) != loginURL { | |
| 119 | + | t.Fatalf("clipboard payload = %q, want %q", got, loginURL) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestInteractiveAltCDoesNotHoldMutexDuringTerminalWrite(t *testing.T) { | |
| 124 | + | t.Setenv("TERM_PROGRAM", "vev") | |
| 125 | + | term := &blockingLoginClipboardTerminal{started: make(chan struct{}), release: make(chan struct{})} | |
| 126 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |
| 127 | + | i.dialog.Open(t.TempDir()) | |
| 128 | + | i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz") | |
| 129 | + | ||
| 130 | + | done := make(chan struct{}) | |
| 131 | + | go func() { | |
| 132 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |
| 133 | + | close(done) | |
| 134 | + | }() | |
| 135 | + | <-term.started | |
| 136 | + | ||
| 137 | + | unlocked := make(chan struct{}) | |
| 138 | + | go func() { | |
| 139 | + | i.mu.Lock() | |
| 140 | + | close(unlocked) | |
| 141 | + | i.mu.Unlock() | |
| 142 | + | }() | |
| 143 | + | select { | |
| 144 | + | case <-unlocked: | |
| 145 | + | case <-time.After(time.Second): | |
| 146 | + | t.Fatal("interactive mutex remained locked during terminal write") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | close(term.release) | |
| 150 | + | select { | |
| 151 | + | case <-done: | |
| 152 | + | case <-time.After(time.Second): | |
| 153 | + | t.Fatal("copy did not finish after terminal write unblocked") | |
| 154 | + | } | |
| 155 | + | } | |
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package modes | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/zut/packages/tui" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type blockingLoginClipboardTerminal struct { | |||
| 18 | + | alertTestTerminal | |||
| 19 | + | started chan struct{} | |||
| 20 | + | release chan struct{} | |||
| 21 | + | once sync.Once | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (t *blockingLoginClipboardTerminal) Write(p []byte) (int, error) { | |||
| 25 | + | t.once.Do(func() { close(t.started) }) | |||
| 26 | + | <-t.release | |||
| 27 | + | return t.alertTestTerminal.Write(p) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (t *blockingLoginClipboardTerminal) WriteString(s string) (int, error) { | |||
| 31 | + | return t.Write([]byte(s)) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestInteractiveAltCUsesSystemClipboardWithoutTerminal(t *testing.T) { | |||
| 35 | + | dir := t.TempDir() | |||
| 36 | + | payload := filepath.Join(dir, "clipboard-payload") | |||
| 37 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | t.Setenv("PATH", dir) | |||
| 41 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 42 | + | t.Setenv("DISPLAY", "") | |||
| 43 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |||
| 44 | + | ||||
| 45 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |||
| 46 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 47 | + | i.dialog.Open(t.TempDir()) | |||
| 48 | + | i.dialog.ShowWaiting(loginURL) | |||
| 49 | + | ||||
| 50 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |||
| 51 | + | ||||
| 52 | + | got, err := os.ReadFile(payload) | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | if string(got) != loginURL { | |||
| 57 | + | t.Fatalf("clipboard payload = %q, want %q", got, loginURL) | |||
| 58 | + | } | |||
| 59 | + | i.mu.Lock() | |||
| 60 | + | defer i.mu.Unlock() | |||
| 61 | + | if i.statusOK != "login URL copied" || i.statusErr != "" { | |||
| 62 | + | t.Fatalf("status = (%q, %q), want successful system copy", i.statusOK, i.statusErr) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestInteractiveAltCReportsSystemClipboardError(t *testing.T) { | |||
| 67 | + | dir := t.TempDir() | |||
| 68 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | t.Setenv("PATH", dir) | |||
| 72 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 73 | + | t.Setenv("DISPLAY", "") | |||
| 74 | + | ||||
| 75 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 76 | + | i.dialog.Open(t.TempDir()) | |||
| 77 | + | i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz") | |||
| 78 | + | ||||
| 79 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |||
| 80 | + | ||||
| 81 | + | i.mu.Lock() | |||
| 82 | + | defer i.mu.Unlock() | |||
| 83 | + | if !strings.Contains(i.statusErr, "exit status 23") { | |||
| 84 | + | t.Fatalf("status error = %q, want clipboard command failure", i.statusErr) | |||
| 85 | + | } | |||
| 86 | + | if i.statusOK != "" { | |||
| 87 | + | t.Fatalf("success status = %q, want empty", i.statusOK) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestInteractiveAltCUsesSystemClipboardWhenOSC52IsUnknown(t *testing.T) { | |||
| 92 | + | dir := t.TempDir() | |||
| 93 | + | payload := filepath.Join(dir, "clipboard-payload") | |||
| 94 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | t.Setenv("PATH", dir) | |||
| 98 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 99 | + | t.Setenv("DISPLAY", "") | |||
| 100 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 101 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |||
| 102 | + | ||||
| 103 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |||
| 104 | + | term := &alertTestTerminal{} | |||
| 105 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |||
| 106 | + | i.dialog.Open(t.TempDir()) | |||
| 107 | + | i.dialog.ShowWaiting(loginURL) | |||
| 108 | + | ||||
| 109 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |||
| 110 | + | ||||
| 111 | + | if got := term.String(); got != "" { | |||
| 112 | + | t.Fatalf("terminal output = %q, want no OSC 52 request", got) | |||
| 113 | + | } | |||
| 114 | + | got, err := os.ReadFile(payload) | |||
| 115 | + | if err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | if string(got) != loginURL { | |||
| 119 | + | t.Fatalf("clipboard payload = %q, want %q", got, loginURL) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestInteractiveAltCDoesNotHoldMutexDuringTerminalWrite(t *testing.T) { | |||
| 124 | + | t.Setenv("TERM_PROGRAM", "vev") | |||
| 125 | + | term := &blockingLoginClipboardTerminal{started: make(chan struct{}), release: make(chan struct{})} | |||
| 126 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |||
| 127 | + | i.dialog.Open(t.TempDir()) | |||
| 128 | + | i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz") | |||
| 129 | + | ||||
| 130 | + | done := make(chan struct{}) | |||
| 131 | + | go func() { | |||
| 132 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |||
| 133 | + | close(done) | |||
| 134 | + | }() | |||
| 135 | + | <-term.started | |||
| 136 | + | ||||
| 137 | + | unlocked := make(chan struct{}) | |||
| 138 | + | go func() { | |||
| 139 | + | i.mu.Lock() | |||
| 140 | + | close(unlocked) | |||
| 141 | + | i.mu.Unlock() | |||
| 142 | + | }() | |||
| 143 | + | select { | |||
| 144 | + | case <-unlocked: | |||
| 145 | + | case <-time.After(time.Second): | |||
| 146 | + | t.Fatal("interactive mutex remained locked during terminal write") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | close(term.release) | |||
| 150 | + | select { | |||
| 151 | + | case <-done: | |||
| 152 | + | case <-time.After(time.Second): | |||
| 153 | + | t.Fatal("copy did not finish after terminal write unblocked") | |||
| 154 | + | } | |||
| 155 | + | } | |||
M packages/agent/modes/login_dialog_test.go +80 -0
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 4 | 7 | "strings" | |
| 5 | 8 | "testing" | |
| 6 | 9 | ||
| @@ -88,3 +91,80 @@ func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |||
| 88 | 91 | t.Fatalf("CursorPos row %d = %q; want editor input row", row, got) | |
| 89 | 92 | } | |
| 90 | 93 | } | |
| 94 | + | ||
| 95 | + | func TestLoginDialogWaitingShowsCopyURLShortcut(t *testing.T) { | |
| 96 | + | for _, provider := range []string{"openai-codex", "kimi", "xai", "github-copilot"} { | |
| 97 | + | t.Run(provider, func(t *testing.T) { | |
| 98 | + | d := newLoginDialog() | |
| 99 | + | d.Open(t.TempDir()) | |
| 100 | + | d.method = "oauth" | |
| 101 | + | d.provider = provider | |
| 102 | + | d.ShowWaiting("https://example.com/oauth/authorize?state=xyz") | |
| 103 | + | ||
| 104 | + | lines := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 105 | + | if !strings.Contains(lines, "alt+c copies URL") { | |
| 106 | + | t.Fatalf("login dialog does not advertise the copy shortcut: %q", lines) | |
| 107 | + | } | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestInteractiveAltCSendsLoginURLToTerminalClipboard(t *testing.T) { | |
| 113 | + | t.Setenv("PATH", t.TempDir()) | |
| 114 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 115 | + | t.Setenv("DISPLAY", "") | |
| 116 | + | t.Setenv("TERM_PROGRAM", "vev") | |
| 117 | + | ||
| 118 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |
| 119 | + | term := &alertTestTerminal{} | |
| 120 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |
| 121 | + | i.dialog.Open(t.TempDir()) | |
| 122 | + | i.dialog.method = "oauth" | |
| 123 | + | i.dialog.provider = "openai-codex" | |
| 124 | + | i.dialog.ShowWaiting(loginURL) | |
| 125 | + | i.dialog.Render(tui.Theme{}, 80) | |
| 126 | + | ||
| 127 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |
| 128 | + | ||
| 129 | + | const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a" | |
| 130 | + | if got := term.String(); got != want { | |
| 131 | + | t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want) | |
| 132 | + | } | |
| 133 | + | if got := i.dialog.codeEd.SubmitValue(); got != "" { | |
| 134 | + | t.Fatalf("authorization code input = %q after alt+c, want unchanged", got) | |
| 135 | + | } | |
| 136 | + | i.mu.Lock() | |
| 137 | + | defer i.mu.Unlock() | |
| 138 | + | if i.statusOK != "sent login URL to terminal clipboard" || i.statusErr != "" { | |
| 139 | + | t.Fatalf("status = (%q, %q), want successful terminal copy request", i.statusOK, i.statusErr) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T) { | |
| 144 | + | dir := t.TempDir() | |
| 145 | + | state := filepath.Join(dir, "wl-copy-called") | |
| 146 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\n: > \"$CLIPBOARD_TEST_STATE\"\n"), 0o700); err != nil { | |
| 147 | + | t.Fatal(err) | |
| 148 | + | } | |
| 149 | + | t.Setenv("PATH", dir) | |
| 150 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 151 | + | t.Setenv("DISPLAY", "") | |
| 152 | + | t.Setenv("TERM_PROGRAM", "vev") | |
| 153 | + | t.Setenv("CLIPBOARD_TEST_STATE", state) | |
| 154 | + | ||
| 155 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |
| 156 | + | term := &alertTestTerminal{} | |
| 157 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |
| 158 | + | i.dialog.Open(t.TempDir()) | |
| 159 | + | i.dialog.ShowWaiting(loginURL) | |
| 160 | + | ||
| 161 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |
| 162 | + | ||
| 163 | + | const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a" | |
| 164 | + | if got := term.String(); got != want { | |
| 165 | + | t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want) | |
| 166 | + | } | |
| 167 | + | if _, err := os.Stat(state); !os.IsNotExist(err) { | |
| 168 | + | t.Fatalf("system clipboard helper ran: %v", err) | |
| 169 | + | } | |
| 170 | + | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 4 | "strings" | 7 | "strings" | ||
| 5 | "testing" | 8 | "testing" | ||
| 6 | 9 | ||||
| @@ -88,3 +91,80 @@ func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |||||
| 88 | t.Fatalf("CursorPos row %d = %q; want editor input row", row, got) | 91 | t.Fatalf("CursorPos row %d = %q; want editor input row", row, got) | ||
| 89 | } | 92 | } | ||
| 90 | } | 93 | } | ||
| 94 | + | ||||
| 95 | + | func TestLoginDialogWaitingShowsCopyURLShortcut(t *testing.T) { | |||
| 96 | + | for _, provider := range []string{"openai-codex", "kimi", "xai", "github-copilot"} { | |||
| 97 | + | t.Run(provider, func(t *testing.T) { | |||
| 98 | + | d := newLoginDialog() | |||
| 99 | + | d.Open(t.TempDir()) | |||
| 100 | + | d.method = "oauth" | |||
| 101 | + | d.provider = provider | |||
| 102 | + | d.ShowWaiting("https://example.com/oauth/authorize?state=xyz") | |||
| 103 | + | ||||
| 104 | + | lines := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 105 | + | if !strings.Contains(lines, "alt+c copies URL") { | |||
| 106 | + | t.Fatalf("login dialog does not advertise the copy shortcut: %q", lines) | |||
| 107 | + | } | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestInteractiveAltCSendsLoginURLToTerminalClipboard(t *testing.T) { | |||
| 113 | + | t.Setenv("PATH", t.TempDir()) | |||
| 114 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 115 | + | t.Setenv("DISPLAY", "") | |||
| 116 | + | t.Setenv("TERM_PROGRAM", "vev") | |||
| 117 | + | ||||
| 118 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |||
| 119 | + | term := &alertTestTerminal{} | |||
| 120 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |||
| 121 | + | i.dialog.Open(t.TempDir()) | |||
| 122 | + | i.dialog.method = "oauth" | |||
| 123 | + | i.dialog.provider = "openai-codex" | |||
| 124 | + | i.dialog.ShowWaiting(loginURL) | |||
| 125 | + | i.dialog.Render(tui.Theme{}, 80) | |||
| 126 | + | ||||
| 127 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |||
| 128 | + | ||||
| 129 | + | const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a" | |||
| 130 | + | if got := term.String(); got != want { | |||
| 131 | + | t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want) | |||
| 132 | + | } | |||
| 133 | + | if got := i.dialog.codeEd.SubmitValue(); got != "" { | |||
| 134 | + | t.Fatalf("authorization code input = %q after alt+c, want unchanged", got) | |||
| 135 | + | } | |||
| 136 | + | i.mu.Lock() | |||
| 137 | + | defer i.mu.Unlock() | |||
| 138 | + | if i.statusOK != "sent login URL to terminal clipboard" || i.statusErr != "" { | |||
| 139 | + | t.Fatalf("status = (%q, %q), want successful terminal copy request", i.statusOK, i.statusErr) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T) { | |||
| 144 | + | dir := t.TempDir() | |||
| 145 | + | state := filepath.Join(dir, "wl-copy-called") | |||
| 146 | + | if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\n: > \"$CLIPBOARD_TEST_STATE\"\n"), 0o700); err != nil { | |||
| 147 | + | t.Fatal(err) | |||
| 148 | + | } | |||
| 149 | + | t.Setenv("PATH", dir) | |||
| 150 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 151 | + | t.Setenv("DISPLAY", "") | |||
| 152 | + | t.Setenv("TERM_PROGRAM", "vev") | |||
| 153 | + | t.Setenv("CLIPBOARD_TEST_STATE", state) | |||
| 154 | + | ||||
| 155 | + | const loginURL = "https://example.com/oauth/authorize?state=xyz" | |||
| 156 | + | term := &alertTestTerminal{} | |||
| 157 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |||
| 158 | + | i.dialog.Open(t.TempDir()) | |||
| 159 | + | i.dialog.ShowWaiting(loginURL) | |||
| 160 | + | ||||
| 161 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true}) | |||
| 162 | + | ||||
| 163 | + | const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a" | |||
| 164 | + | if got := term.String(); got != want { | |||
| 165 | + | t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want) | |||
| 166 | + | } | |||
| 167 | + | if _, err := os.Stat(state); !os.IsNotExist(err) { | |||
| 168 | + | t.Fatalf("system clipboard helper ran: %v", err) | |||
| 169 | + | } | |||
| 170 | + | } | |||
M packages/tui/clipboard_linux_test.go +26 -0
| @@ -111,6 +111,32 @@ exit 1 | |||
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | func TestWriteClipboardTextWaylandUsesWlCopy(t *testing.T) { | |
| 115 | + | dir := t.TempDir() | |
| 116 | + | payload := filepath.Join(dir, "clipboard-payload") | |
| 117 | + | writeClipboardTestHelper(t, dir, "wl-copy", `#!/bin/sh | |
| 118 | + | if [ "$1" != "--type" ] || [ "$2" != "text/plain" ]; then | |
| 119 | + | exit 1 | |
| 120 | + | fi | |
| 121 | + | exec /bin/cat > "$CLIPBOARD_TEST_PAYLOAD" | |
| 122 | + | `) | |
| 123 | + | t.Setenv("PATH", dir) | |
| 124 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 125 | + | t.Setenv("DISPLAY", "") | |
| 126 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |
| 127 | + | ||
| 128 | + | if err := WriteClipboardText(context.Background(), "https://example.com/oauth?state=xyz"); err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | got, err := os.ReadFile(payload) | |
| 132 | + | if err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | if string(got) != "https://example.com/oauth?state=xyz" { | |
| 136 | + | t.Fatalf("clipboard payload = %q", got) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 114 | 140 | func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) { | |
| 115 | 141 | dir := t.TempDir() | |
| 116 | 142 | payload := filepath.Join(dir, "payload") | |
| @@ -111,6 +111,32 @@ exit 1 | |||||
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | + | func TestWriteClipboardTextWaylandUsesWlCopy(t *testing.T) { | |||
| 115 | + | dir := t.TempDir() | |||
| 116 | + | payload := filepath.Join(dir, "clipboard-payload") | |||
| 117 | + | writeClipboardTestHelper(t, dir, "wl-copy", `#!/bin/sh | |||
| 118 | + | if [ "$1" != "--type" ] || [ "$2" != "text/plain" ]; then | |||
| 119 | + | exit 1 | |||
| 120 | + | fi | |||
| 121 | + | exec /bin/cat > "$CLIPBOARD_TEST_PAYLOAD" | |||
| 122 | + | `) | |||
| 123 | + | t.Setenv("PATH", dir) | |||
| 124 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 125 | + | t.Setenv("DISPLAY", "") | |||
| 126 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |||
| 127 | + | ||||
| 128 | + | if err := WriteClipboardText(context.Background(), "https://example.com/oauth?state=xyz"); err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | got, err := os.ReadFile(payload) | |||
| 132 | + | if err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | if string(got) != "https://example.com/oauth?state=xyz" { | |||
| 136 | + | t.Fatalf("clipboard payload = %q", got) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 114 | func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) { | 140 | func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) { | ||
| 115 | dir := t.TempDir() | 141 | dir := t.TempDir() | ||
| 116 | payload := filepath.Join(dir, "payload") | 142 | payload := filepath.Join(dir, "payload") | ||
M packages/tui/clipboard_text_command.go +29 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "os/exec" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -42,3 +43,31 @@ func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool, | |||
| 42 | 43 | } | |
| 43 | 44 | return "", false, lastErr | |
| 44 | 45 | } | |
| 46 | + | ||
| 47 | + | // writeClipboardTextCommands tries each installed command in order. Text is | |
| 48 | + | // supplied on stdin so it never becomes part of an executable command line. | |
| 49 | + | func writeClipboardTextCommands(ctx context.Context, text string, commands ...clipboardTextCommand) error { | |
| 50 | + | var lastErr error | |
| 51 | + | found := false | |
| 52 | + | for _, candidate := range commands { | |
| 53 | + | path, err := exec.LookPath(candidate.name) | |
| 54 | + | if err != nil { | |
| 55 | + | continue | |
| 56 | + | } | |
| 57 | + | found = true | |
| 58 | + | commandCtx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 59 | + | cmd := exec.CommandContext(commandCtx, path, candidate.args...) | |
| 60 | + | cmd.Stdin = strings.NewReader(text) | |
| 61 | + | err = cmd.Run() | |
| 62 | + | cancel() | |
| 63 | + | if err != nil { | |
| 64 | + | lastErr = err | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | if !found { | |
| 70 | + | return errClipboardCommandUnavailable | |
| 71 | + | } | |
| 72 | + | return lastErr | |
| 73 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "os/exec" | 6 | "os/exec" | ||
| 7 | + | "strings" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -42,3 +43,31 @@ func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool, | |||||
| 42 | } | 43 | } | ||
| 43 | return "", false, lastErr | 44 | return "", false, lastErr | ||
| 44 | } | 45 | } | ||
| 46 | + | ||||
| 47 | + | // writeClipboardTextCommands tries each installed command in order. Text is | |||
| 48 | + | // supplied on stdin so it never becomes part of an executable command line. | |||
| 49 | + | func writeClipboardTextCommands(ctx context.Context, text string, commands ...clipboardTextCommand) error { | |||
| 50 | + | var lastErr error | |||
| 51 | + | found := false | |||
| 52 | + | for _, candidate := range commands { | |||
| 53 | + | path, err := exec.LookPath(candidate.name) | |||
| 54 | + | if err != nil { | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 57 | + | found = true | |||
| 58 | + | commandCtx, cancel := context.WithTimeout(ctx, 5*time.Second) | |||
| 59 | + | cmd := exec.CommandContext(commandCtx, path, candidate.args...) | |||
| 60 | + | cmd.Stdin = strings.NewReader(text) | |||
| 61 | + | err = cmd.Run() | |||
| 62 | + | cancel() | |||
| 63 | + | if err != nil { | |||
| 64 | + | lastErr = err | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | if !found { | |||
| 70 | + | return errClipboardCommandUnavailable | |||
| 71 | + | } | |||
| 72 | + | return lastErr | |||
| 73 | + | } | |||
M packages/tui/clipboard_text_command_test.go +72 -0
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 4 | 7 | "os" | |
| 5 | 8 | "path/filepath" | |
| 6 | 9 | "runtime" | |
| @@ -34,3 +37,72 @@ func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) { | |||
| 34 | 37 | t.Fatalf("ok = %v, err = %v", ok, err) | |
| 35 | 38 | } | |
| 36 | 39 | } | |
| 40 | + | ||
| 41 | + | func TestWriteClipboardTextCommandsPassesTextOnStdin(t *testing.T) { | |
| 42 | + | if runtime.GOOS == "windows" { | |
| 43 | + | t.Skip("test helper is a shell script") | |
| 44 | + | } | |
| 45 | + | dir := t.TempDir() | |
| 46 | + | payload := filepath.Join(dir, "clipboard-payload") | |
| 47 | + | path := filepath.Join(dir, "clipboard-test") | |
| 48 | + | if err := os.WriteFile(path, []byte("#!/bin/sh\n/bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | t.Setenv("PATH", dir) | |
| 52 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |
| 53 | + | ||
| 54 | + | if err := writeClipboardTextCommands(context.Background(), "https://example.com/oauth?state=xyz", clipboardTextCommand{name: "clipboard-test"}); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | got, err := os.ReadFile(payload) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if string(got) != "https://example.com/oauth?state=xyz" { | |
| 62 | + | t.Fatalf("clipboard payload = %q", got) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestWriteClipboardTextCommandsHonorsCancellation(t *testing.T) { | |
| 67 | + | if runtime.GOOS == "windows" { | |
| 68 | + | t.Skip("test helper is a shell script") | |
| 69 | + | } | |
| 70 | + | dir := t.TempDir() | |
| 71 | + | path := filepath.Join(dir, "clipboard-test") | |
| 72 | + | if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | t.Setenv("PATH", dir) | |
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 77 | + | cancel() | |
| 78 | + | ||
| 79 | + | err := writeClipboardTextCommands(ctx, "clipboard text", clipboardTextCommand{name: "clipboard-test"}) | |
| 80 | + | if !errors.Is(err, context.Canceled) { | |
| 81 | + | t.Fatalf("error = %v, want context cancellation", err) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestWriteClipboardTextOSC52EncodesPayload(t *testing.T) { | |
| 86 | + | var out bytes.Buffer | |
| 87 | + | if err := WriteClipboardTextOSC52(&out, "https://example.com/oauth?state=xyz"); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aD9zdGF0ZT14eXo=\a" | |
| 91 | + | if got := out.String(); got != want { | |
| 92 | + | t.Fatalf("OSC 52 = %q, want %q", got, want) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestSupportsOSC52ClipboardForVev(t *testing.T) { | |
| 97 | + | t.Setenv("TERM_PROGRAM", "vev") | |
| 98 | + | if !SupportsOSC52Clipboard() { | |
| 99 | + | t.Fatal("Vev terminal did not advertise OSC 52 clipboard support") | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestSupportsOSC52ClipboardRejectsUnknownTerminal(t *testing.T) { | |
| 104 | + | t.Setenv("TERM_PROGRAM", "") | |
| 105 | + | if SupportsOSC52Clipboard() { | |
| 106 | + | t.Fatal("unknown terminal advertised OSC 52 clipboard support") | |
| 107 | + | } | |
| 108 | + | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 4 | "os" | 7 | "os" | ||
| 5 | "path/filepath" | 8 | "path/filepath" | ||
| 6 | "runtime" | 9 | "runtime" | ||
| @@ -34,3 +37,72 @@ func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) { | |||||
| 34 | t.Fatalf("ok = %v, err = %v", ok, err) | 37 | t.Fatalf("ok = %v, err = %v", ok, err) | ||
| 35 | } | 38 | } | ||
| 36 | } | 39 | } | ||
| 40 | + | ||||
| 41 | + | func TestWriteClipboardTextCommandsPassesTextOnStdin(t *testing.T) { | |||
| 42 | + | if runtime.GOOS == "windows" { | |||
| 43 | + | t.Skip("test helper is a shell script") | |||
| 44 | + | } | |||
| 45 | + | dir := t.TempDir() | |||
| 46 | + | payload := filepath.Join(dir, "clipboard-payload") | |||
| 47 | + | path := filepath.Join(dir, "clipboard-test") | |||
| 48 | + | if err := os.WriteFile(path, []byte("#!/bin/sh\n/bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | t.Setenv("PATH", dir) | |||
| 52 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |||
| 53 | + | ||||
| 54 | + | if err := writeClipboardTextCommands(context.Background(), "https://example.com/oauth?state=xyz", clipboardTextCommand{name: "clipboard-test"}); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | got, err := os.ReadFile(payload) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | if string(got) != "https://example.com/oauth?state=xyz" { | |||
| 62 | + | t.Fatalf("clipboard payload = %q", got) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestWriteClipboardTextCommandsHonorsCancellation(t *testing.T) { | |||
| 67 | + | if runtime.GOOS == "windows" { | |||
| 68 | + | t.Skip("test helper is a shell script") | |||
| 69 | + | } | |||
| 70 | + | dir := t.TempDir() | |||
| 71 | + | path := filepath.Join(dir, "clipboard-test") | |||
| 72 | + | if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | t.Setenv("PATH", dir) | |||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 77 | + | cancel() | |||
| 78 | + | ||||
| 79 | + | err := writeClipboardTextCommands(ctx, "clipboard text", clipboardTextCommand{name: "clipboard-test"}) | |||
| 80 | + | if !errors.Is(err, context.Canceled) { | |||
| 81 | + | t.Fatalf("error = %v, want context cancellation", err) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestWriteClipboardTextOSC52EncodesPayload(t *testing.T) { | |||
| 86 | + | var out bytes.Buffer | |||
| 87 | + | if err := WriteClipboardTextOSC52(&out, "https://example.com/oauth?state=xyz"); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aD9zdGF0ZT14eXo=\a" | |||
| 91 | + | if got := out.String(); got != want { | |||
| 92 | + | t.Fatalf("OSC 52 = %q, want %q", got, want) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestSupportsOSC52ClipboardForVev(t *testing.T) { | |||
| 97 | + | t.Setenv("TERM_PROGRAM", "vev") | |||
| 98 | + | if !SupportsOSC52Clipboard() { | |||
| 99 | + | t.Fatal("Vev terminal did not advertise OSC 52 clipboard support") | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestSupportsOSC52ClipboardRejectsUnknownTerminal(t *testing.T) { | |||
| 104 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 105 | + | if SupportsOSC52Clipboard() { | |||
| 106 | + | t.Fatal("unknown terminal advertised OSC 52 clipboard support") | |||
| 107 | + | } | |||
| 108 | + | } | |||
M packages/tui/clipboard_text_darwin.go +15 -1
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | - | import "fmt" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | // ReadClipboardText reads plain text from the macOS system clipboard. | |
| 8 | 11 | func ReadClipboardText() (string, bool, error) { | |
| @@ -12,3 +15,14 @@ func ReadClipboardText() (string, bool, error) { | |||
| 12 | 15 | } | |
| 13 | 16 | return text, ok, err | |
| 14 | 17 | } | |
| 18 | + | ||
| 19 | + | // WriteClipboardText writes plain text to the macOS system clipboard. | |
| 20 | + | func WriteClipboardText(ctx context.Context, text string) error { | |
| 21 | + | if err := writeClipboardTextCommands(ctx, text, clipboardTextCommand{name: "/usr/bin/pbcopy"}); err != nil { | |
| 22 | + | if err == errClipboardCommandUnavailable { | |
| 23 | + | return fmt.Errorf("text clipboard unavailable: pbcopy was not found") | |
| 24 | + | } | |
| 25 | + | return fmt.Errorf("write text clipboard: %w", err) | |
| 26 | + | } | |
| 27 | + | return nil | |
| 28 | + | } | |
| @@ -2,7 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | package tui | 3 | package tui | ||
| 4 | 4 | ||||
| 5 | - | import "fmt" | 5 | + | import ( |
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | // ReadClipboardText reads plain text from the macOS system clipboard. | 10 | // ReadClipboardText reads plain text from the macOS system clipboard. | ||
| 8 | func ReadClipboardText() (string, bool, error) { | 11 | func ReadClipboardText() (string, bool, error) { | ||
| @@ -12,3 +15,14 @@ func ReadClipboardText() (string, bool, error) { | |||||
| 12 | } | 15 | } | ||
| 13 | return text, ok, err | 16 | return text, ok, err | ||
| 14 | } | 17 | } | ||
| 18 | + | ||||
| 19 | + | // WriteClipboardText writes plain text to the macOS system clipboard. | |||
| 20 | + | func WriteClipboardText(ctx context.Context, text string) error { | |||
| 21 | + | if err := writeClipboardTextCommands(ctx, text, clipboardTextCommand{name: "/usr/bin/pbcopy"}); err != nil { | |||
| 22 | + | if err == errClipboardCommandUnavailable { | |||
| 23 | + | return fmt.Errorf("text clipboard unavailable: pbcopy was not found") | |||
| 24 | + | } | |||
| 25 | + | return fmt.Errorf("write text clipboard: %w", err) | |||
| 26 | + | } | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
M packages/tui/clipboard_text_linux.go +30 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 9 | ) | |
| @@ -38,3 +39,32 @@ func ReadClipboardText() (string, bool, error) { | |||
| 38 | 39 | } | |
| 39 | 40 | return text, ok, nil | |
| 40 | 41 | } | |
| 42 | + | ||
| 43 | + | // WriteClipboardText writes plain text to a Wayland or X11 clipboard. Small | |
| 44 | + | // command-line clients keep zut's build CGO-free. | |
| 45 | + | func WriteClipboardText(ctx context.Context, text string) error { | |
| 46 | + | var commands []clipboardTextCommand | |
| 47 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 48 | + | commands = append(commands, clipboardTextCommand{name: "wl-copy", args: []string{"--type", "text/plain"}}) | |
| 49 | + | } | |
| 50 | + | if os.Getenv("DISPLAY") != "" { | |
| 51 | + | commands = append(commands, | |
| 52 | + | clipboardTextCommand{name: "xclip", args: []string{"-selection", "clipboard", "-in"}}, | |
| 53 | + | clipboardTextCommand{name: "xsel", args: []string{"--clipboard", "--input"}}, | |
| 54 | + | ) | |
| 55 | + | } | |
| 56 | + | if len(commands) == 0 { | |
| 57 | + | commands = []clipboardTextCommand{ | |
| 58 | + | {name: "wl-copy", args: []string{"--type", "text/plain"}}, | |
| 59 | + | {name: "xclip", args: []string{"-selection", "clipboard", "-in"}}, | |
| 60 | + | {name: "xsel", args: []string{"--clipboard", "--input"}}, | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | if err := writeClipboardTextCommands(ctx, text, commands...); err != nil { | |
| 64 | + | if err == errClipboardCommandUnavailable { | |
| 65 | + | return fmt.Errorf("text clipboard unavailable: install wl-clipboard, xclip, or xsel") | |
| 66 | + | } | |
| 67 | + | return fmt.Errorf("write text clipboard: %w", err) | |
| 68 | + | } | |
| 69 | + | return nil | |
| 70 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package tui | 3 | package tui | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | ) | 9 | ) | ||
| @@ -38,3 +39,32 @@ func ReadClipboardText() (string, bool, error) { | |||||
| 38 | } | 39 | } | ||
| 39 | return text, ok, nil | 40 | return text, ok, nil | ||
| 40 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | // WriteClipboardText writes plain text to a Wayland or X11 clipboard. Small | |||
| 44 | + | // command-line clients keep zut's build CGO-free. | |||
| 45 | + | func WriteClipboardText(ctx context.Context, text string) error { | |||
| 46 | + | var commands []clipboardTextCommand | |||
| 47 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |||
| 48 | + | commands = append(commands, clipboardTextCommand{name: "wl-copy", args: []string{"--type", "text/plain"}}) | |||
| 49 | + | } | |||
| 50 | + | if os.Getenv("DISPLAY") != "" { | |||
| 51 | + | commands = append(commands, | |||
| 52 | + | clipboardTextCommand{name: "xclip", args: []string{"-selection", "clipboard", "-in"}}, | |||
| 53 | + | clipboardTextCommand{name: "xsel", args: []string{"--clipboard", "--input"}}, | |||
| 54 | + | ) | |||
| 55 | + | } | |||
| 56 | + | if len(commands) == 0 { | |||
| 57 | + | commands = []clipboardTextCommand{ | |||
| 58 | + | {name: "wl-copy", args: []string{"--type", "text/plain"}}, | |||
| 59 | + | {name: "xclip", args: []string{"-selection", "clipboard", "-in"}}, | |||
| 60 | + | {name: "xsel", args: []string{"--clipboard", "--input"}}, | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | if err := writeClipboardTextCommands(ctx, text, commands...); err != nil { | |||
| 64 | + | if err == errClipboardCommandUnavailable { | |||
| 65 | + | return fmt.Errorf("text clipboard unavailable: install wl-clipboard, xclip, or xsel") | |||
| 66 | + | } | |||
| 67 | + | return fmt.Errorf("write text clipboard: %w", err) | |||
| 68 | + | } | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
M packages/tui/clipboard_text_stub.go +8 -1
| @@ -2,8 +2,15 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | - | import "fmt" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | func ReadClipboardText() (string, bool, error) { | |
| 8 | 11 | return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut") | |
| 9 | 12 | } | |
| 13 | + | ||
| 14 | + | func WriteClipboardText(context.Context, string) error { | |
| 15 | + | return fmt.Errorf("text clipboard is not supported on this platform") | |
| 16 | + | } | |
| @@ -2,8 +2,15 @@ | |||||
| 2 | 2 | ||||
| 3 | package tui | 3 | package tui | ||
| 4 | 4 | ||||
| 5 | - | import "fmt" | 5 | + | import ( |
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | func ReadClipboardText() (string, bool, error) { | 10 | func ReadClipboardText() (string, bool, error) { | ||
| 8 | return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut") | 11 | return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut") | ||
| 9 | } | 12 | } | ||
| 13 | + | ||||
| 14 | + | func WriteClipboardText(context.Context, string) error { | |||
| 15 | + | return fmt.Errorf("text clipboard is not supported on this platform") | |||
| 16 | + | } | |||
M packages/tui/clipboard_text_windows.go +19 -1
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | - | import "fmt" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | // ReadClipboardText reads plain text through the PowerShell installation | |
| 8 | 11 | // included with supported Windows versions. | |
| @@ -19,3 +22,18 @@ func ReadClipboardText() (string, bool, error) { | |||
| 19 | 22 | } | |
| 20 | 23 | return text, ok, nil | |
| 21 | 24 | } | |
| 25 | + | ||
| 26 | + | // WriteClipboardText writes plain text through the PowerShell installation | |
| 27 | + | // included with supported Windows versions. | |
| 28 | + | func WriteClipboardText(ctx context.Context, text string) error { | |
| 29 | + | if err := writeClipboardTextCommands(ctx, text, | |
| 30 | + | clipboardTextCommand{name: "powershell.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())"}}, | |
| 31 | + | clipboardTextCommand{name: "pwsh.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())"}}, | |
| 32 | + | ); err != nil { | |
| 33 | + | if err == errClipboardCommandUnavailable { | |
| 34 | + | return fmt.Errorf("text clipboard unavailable: PowerShell was not found") | |
| 35 | + | } | |
| 36 | + | return fmt.Errorf("write text clipboard: %w", err) | |
| 37 | + | } | |
| 38 | + | return nil | |
| 39 | + | } | |
| @@ -2,7 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | package tui | 3 | package tui | ||
| 4 | 4 | ||||
| 5 | - | import "fmt" | 5 | + | import ( |
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | // ReadClipboardText reads plain text through the PowerShell installation | 10 | // ReadClipboardText reads plain text through the PowerShell installation | ||
| 8 | // included with supported Windows versions. | 11 | // included with supported Windows versions. | ||
| @@ -19,3 +22,18 @@ func ReadClipboardText() (string, bool, error) { | |||||
| 19 | } | 22 | } | ||
| 20 | return text, ok, nil | 23 | return text, ok, nil | ||
| 21 | } | 24 | } | ||
| 25 | + | ||||
| 26 | + | // WriteClipboardText writes plain text through the PowerShell installation | |||
| 27 | + | // included with supported Windows versions. | |||
| 28 | + | func WriteClipboardText(ctx context.Context, text string) error { | |||
| 29 | + | if err := writeClipboardTextCommands(ctx, text, | |||
| 30 | + | clipboardTextCommand{name: "powershell.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())"}}, | |||
| 31 | + | clipboardTextCommand{name: "pwsh.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())"}}, | |||
| 32 | + | ); err != nil { | |||
| 33 | + | if err == errClipboardCommandUnavailable { | |||
| 34 | + | return fmt.Errorf("text clipboard unavailable: PowerShell was not found") | |||
| 35 | + | } | |||
| 36 | + | return fmt.Errorf("write text clipboard: %w", err) | |||
| 37 | + | } | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
M packages/tui/input.go +3 -0
| @@ -506,6 +506,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 506 | 506 | if code >= '0' && code <= '9' { | |
| 507 | 507 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 508 | 508 | } | |
| 509 | + | if !ctrl && code >= 0x20 && code <= 0x7e { | |
| 510 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 511 | + | } | |
| 509 | 512 | return Key{}, false | |
| 510 | 513 | } | |
| 511 | 514 | ||
| @@ -506,6 +506,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 506 | if code >= '0' && code <= '9' { | 506 | if code >= '0' && code <= '9' { | ||
| 507 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | 507 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | ||
| 508 | } | 508 | } | ||
| 509 | + | if !ctrl && code >= 0x20 && code <= 0x7e { | |||
| 510 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |||
| 511 | + | } | |||
| 509 | return Key{}, false | 512 | return Key{}, false | ||
| 510 | } | 513 | } | ||
| 511 | 514 | ||||
M packages/tui/input_test.go +28 -0
| @@ -77,6 +77,34 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |||
| 77 | 77 | } | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | func TestReaderParsesRawAltC(t *testing.T) { | |
| 81 | + | k := readKey(t, "\x1bc") | |
| 82 | + | if k.Kind != KeyRune || k.Rune != 'c' || !k.Alt { | |
| 83 | + | t.Fatalf("Read = %+v, want alt+c", k) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestReaderParsesEnhancedAltC(t *testing.T) { | |
| 88 | + | for name, sequence := range map[string]string{ | |
| 89 | + | "kitty protocol": "\x1b[99;3u", | |
| 90 | + | "modify other keys": "\x1b[27;3;99~", | |
| 91 | + | } { | |
| 92 | + | t.Run(name, func(t *testing.T) { | |
| 93 | + | k := readKey(t, sequence) | |
| 94 | + | if k.Kind != KeyRune || k.Rune != 'c' || !k.Alt { | |
| 95 | + | t.Fatalf("Read = %+v, want alt+c", k) | |
| 96 | + | } | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestReaderKeepsUnknownEnhancedCtrlLetterUnknown(t *testing.T) { | |
| 102 | + | k := readKey(t, "\x1b[122;5u") | |
| 103 | + | if k.Kind != KeyUnknown { | |
| 104 | + | t.Fatalf("Read = %+v, want unknown ctrl+z", k) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 80 | 108 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | |
| 81 | 109 | k := readKey(t, "\x1b[118;5u") | |
| 82 | 110 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| @@ -77,6 +77,34 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |||||
| 77 | } | 77 | } | ||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | + | func TestReaderParsesRawAltC(t *testing.T) { | |||
| 81 | + | k := readKey(t, "\x1bc") | |||
| 82 | + | if k.Kind != KeyRune || k.Rune != 'c' || !k.Alt { | |||
| 83 | + | t.Fatalf("Read = %+v, want alt+c", k) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestReaderParsesEnhancedAltC(t *testing.T) { | |||
| 88 | + | for name, sequence := range map[string]string{ | |||
| 89 | + | "kitty protocol": "\x1b[99;3u", | |||
| 90 | + | "modify other keys": "\x1b[27;3;99~", | |||
| 91 | + | } { | |||
| 92 | + | t.Run(name, func(t *testing.T) { | |||
| 93 | + | k := readKey(t, sequence) | |||
| 94 | + | if k.Kind != KeyRune || k.Rune != 'c' || !k.Alt { | |||
| 95 | + | t.Fatalf("Read = %+v, want alt+c", k) | |||
| 96 | + | } | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestReaderKeepsUnknownEnhancedCtrlLetterUnknown(t *testing.T) { | |||
| 102 | + | k := readKey(t, "\x1b[122;5u") | |||
| 103 | + | if k.Kind != KeyUnknown { | |||
| 104 | + | t.Fatalf("Read = %+v, want unknown ctrl+z", k) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 80 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | 108 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | ||
| 81 | k := readKey(t, "\x1b[118;5u") | 109 | k := readKey(t, "\x1b[118;5u") | ||
| 82 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | 110 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | ||
M packages/tui/terminal.go +16 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/base64" | |
| 4 | 5 | "io" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| @@ -59,6 +60,21 @@ func WriteBell(w io.Writer) error { | |||
| 59 | 60 | return err | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 63 | + | // WriteClipboardTextOSC52 asks the connected terminal to copy text to its | |
| 64 | + | // clipboard. OSC 52 reaches the local terminal through SSH and multiplexers | |
| 65 | + | // where a process-local clipboard command cannot. | |
| 66 | + | func WriteClipboardTextOSC52(w io.Writer, text string) error { | |
| 67 | + | _, err := io.WriteString(w, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(text))+"\a") | |
| 68 | + | return err | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // SupportsOSC52Clipboard reports whether the terminal environment is known to | |
| 72 | + | // relay OSC 52 clipboard requests. Vev uses OSC 52 for its own copy operation | |
| 73 | + | // and exposes TERM_PROGRAM=vev to child processes. | |
| 74 | + | func SupportsOSC52Clipboard() bool { | |
| 75 | + | return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vev") | |
| 76 | + | } | |
| 77 | + | ||
| 62 | 78 | // SetTitle returns an OSC 0 sequence that sets the terminal's tab/window | |
| 63 | 79 | // title. Control characters are removed from the payload so user- or | |
| 64 | 80 | // model-provided text cannot inject another terminal control sequence. | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/base64" | |||
| 4 | "io" | 5 | "io" | ||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| @@ -59,6 +60,21 @@ func WriteBell(w io.Writer) error { | |||||
| 59 | return err | 60 | return err | ||
| 60 | } | 61 | } | ||
| 61 | 62 | ||||
| 63 | + | // WriteClipboardTextOSC52 asks the connected terminal to copy text to its | |||
| 64 | + | // clipboard. OSC 52 reaches the local terminal through SSH and multiplexers | |||
| 65 | + | // where a process-local clipboard command cannot. | |||
| 66 | + | func WriteClipboardTextOSC52(w io.Writer, text string) error { | |||
| 67 | + | _, err := io.WriteString(w, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(text))+"\a") | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // SupportsOSC52Clipboard reports whether the terminal environment is known to | |||
| 72 | + | // relay OSC 52 clipboard requests. Vev uses OSC 52 for its own copy operation | |||
| 73 | + | // and exposes TERM_PROGRAM=vev to child processes. | |||
| 74 | + | func SupportsOSC52Clipboard() bool { | |||
| 75 | + | return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vev") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 62 | // SetTitle returns an OSC 0 sequence that sets the terminal's tab/window | 78 | // SetTitle returns an OSC 0 sequence that sets the terminal's tab/window | ||
| 63 | // title. Control characters are removed from the payload so user- or | 79 | // title. Control characters are removed from the payload so user- or | ||
| 64 | // model-provided text cannot inject another terminal control sequence. | 80 | // model-provided text cannot inject another terminal control sequence. | ||