detached d8a14e7

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 UnverifiedB5690EEEBB952194 The 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) {
150150 if act.SaveLlama {
151151 i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
152152 }
153+ if act.CopyURL {
154+ i.copyLoginURL(ctx)
155+ }
153156 return false
154157 }
155158 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 false156 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 {
213213 }
214214 lines = append(lines, "")
215215 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"))
217217 lines = append(lines, frameRule(th, width))
218218 break
219219 }
@@ -224,7 +224,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
224224 edLines, _, _ := d.codeEd.Render(width - 2)
225225 lines = append(lines, edLines...)
226226 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"))
228228 lines = append(lines, frameRule(th, width))
229229 case loginStepPasteCode:
230230 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 {
244244 edLines, _, _ := d.codeEd.Render(width - 2)
245245 lines = append(lines, edLines...)
246246 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"))
248248 lines = append(lines, frameRule(th, width))
249249 case loginStepInfo:
250250 title := d.infoTitle
@@ -428,6 +428,7 @@ type loginDialogAction struct {
428428 LlamaAPIKey string
429429 Close bool
430430 SubmitCode string
431+ CopyURL bool
431432 }
432433
433434 // HandleKey advances the dialog and returns an action to apply, if any.
@@ -610,6 +611,9 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
610611 d.Close()
611612 return loginDialogAction{Close: true}
612613 }
614+ if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') {
615+ return loginDialogAction{CopyURL: true}
616+ }
613617 if d.codeEd == nil {
614618 return loginDialogAction{}
615619 }
@@ -626,6 +630,9 @@ func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
626630 d.Close()
627631 return loginDialogAction{Close: true}
628632 }
633+ if k.Kind == tui.KeyRune && k.Alt && !k.Ctrl && (k.Rune == 'c' || k.Rune == 'C') {
634+ return loginDialogAction{CopyURL: true}
635+ }
629636 if d.codeEd == nil {
630637 return loginDialogAction{}
631638 }
@@ -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 break218 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.infoTitle250 title := d.infoTitle
@@ -428,6 +428,7 @@ type loginDialogAction struct {
428 LlamaAPIKey string428 LlamaAPIKey string
429 Close bool429 Close bool
430 SubmitCode string430 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 @@
11 package modes
22
33 import (
4+ "context"
5+ "os"
6+ "path/filepath"
47 "strings"
58 "testing"
69
@@ -88,3 +91,80 @@ func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
8891 t.Fatalf("CursorPos row %d = %q; want editor input row", row, got)
8992 }
9093 }
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 modes1 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
111111 }
112112 }
113113
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+
114140 func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) {
115141 dir := t.TempDir()
116142 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 (
44 "context"
55 "errors"
66 "os/exec"
7+ "strings"
78 "time"
89 )
910
@@ -42,3 +43,31 @@ func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool,
4243 }
4344 return "", false, lastErr
4445 }
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, lastErr44 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 @@
11 package tui
22
33 import (
4+ "bytes"
5+ "context"
6+ "errors"
47 "os"
58 "path/filepath"
69 "runtime"
@@ -34,3 +37,72 @@ func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) {
3437 t.Fatalf("ok = %v, err = %v", ok, err)
3538 }
3639 }
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 tui1 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 @@
22
33 package tui
44
5-import "fmt"
5+import (
6+ "context"
7+ "fmt"
8+)
69
710 // ReadClipboardText reads plain text from the macOS system clipboard.
811 func ReadClipboardText() (string, bool, error) {
@@ -12,3 +15,14 @@ func ReadClipboardText() (string, bool, error) {
1215 }
1316 return text, ok, err
1417 }
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 tui3 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, err16 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 @@
33 package tui
44
55 import (
6+ "context"
67 "fmt"
78 "os"
89 )
@@ -38,3 +39,32 @@ func ReadClipboardText() (string, bool, error) {
3839 }
3940 return text, ok, nil
4041 }
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 tui3 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, nil40 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 @@
22
33 package tui
44
5-import "fmt"
5+import (
6+ "context"
7+ "fmt"
8+)
69
710 func ReadClipboardText() (string, bool, error) {
811 return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut")
912 }
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 tui3 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 @@
22
33 package tui
44
5-import "fmt"
5+import (
6+ "context"
7+ "fmt"
8+)
69
710 // ReadClipboardText reads plain text through the PowerShell installation
811 // included with supported Windows versions.
@@ -19,3 +22,18 @@ func ReadClipboardText() (string, bool, error) {
1922 }
2023 return text, ok, nil
2124 }
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 tui3 package tui
4 4
5-import "fmt"5+import (
6+ "context"
7+ "fmt"
8+)
6 9
7 // ReadClipboardText reads plain text through the PowerShell installation10 // 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, nil23 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) {
506506 if code >= '0' && code <= '9' {
507507 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
508508 }
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+ }
509512 return Key{}, false
510513 }
511514
@@ -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}, true507 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{}, false512 return Key{}, false
510 }513 }
511 514
M packages/tui/input_test.go +28 -0
@@ -77,6 +77,34 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
7777 }
7878 }
7979
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+
80108 func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {
81109 k := readKey(t, "\x1b[118;5u")
82110 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 @@
11 package tui
22
33 import (
4+ "encoding/base64"
45 "io"
56 "os"
67 "path/filepath"
@@ -59,6 +60,21 @@ func WriteBell(w io.Writer) error {
5960 return err
6061 }
6162
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+
6278 // SetTitle returns an OSC 0 sequence that sets the terminal's tab/window
6379 // title. Control characters are removed from the payload so user- or
6480 // model-provided text cannot inject another terminal control sequence.
@@ -1,6 +1,7 @@
1 package tui1 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 err60 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/window78 // SetTitle returns an OSC 0 sequence that sets the terminal's tab/window
63 // title. Control characters are removed from the payload so user- or79 // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close