detached 76f8a9f

fix(client): retain clipboard across remote handoffs (#235)

commit
76f8a9f1778dc7ebe319059698dbff331d8f6d7f
author
bnema · · 2026-08-28 18:21 UTC
committer
GitHub · · 2026-08-28 18:21 UTC
parent
fd94d91
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +13 -17
M internal/adapters/clipboard/wlpaste.go +2 -5
@@ -1,11 +1,8 @@
11 //go:build linux
22
33 // Package clipboard implements ports.ClipboardReader over Wayland's
4-// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
5-// attach forward a locally clipped image to the daemon (see
6-// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
7-// xclip or OSC-52 backend could implement the same interface later without
8-// touching the wire protocol.
4+// wl-clipboard CLI (wl-paste). It lets a remote attach forward a locally
5+// clipped image to the daemon.
96 package clipboard
107
118 import (
@@ -1,11 +1,8 @@
1 //go:build linux1 //go:build linux
2 2
3 // Package clipboard implements ports.ClipboardReader over Wayland's3 // Package clipboard implements ports.ClipboardReader over Wayland's
4-// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote4+// wl-clipboard CLI (wl-paste). It lets a remote attach forward a locally
5-// attach forward a locally clipped image to the daemon (see5+// clipped image to the daemon.
6-// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
7-// xclip or OSC-52 backend could implement the same interface later without
8-// touching the wire protocol.
9 package clipboard6 package clipboard
10 7
11 import (8 import (
M internal/app/run.go +4 -4
@@ -811,10 +811,9 @@ type runAttachDeps struct {
811811 runClient runClientFunc
812812 createDetached func(context.Context, string) error
813813 runtimeObserver ports.SerializedRuntimeObserver
814- // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
815- // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
816- // Only used for the remote-dialer branch below; local attaches never
817- // intercept Ctrl+V regardless of this field.
814+ // clipboard reads a clipboard image on a remote route's Ctrl+V.
815+ // The client retains it across local-to-remote handoffs and only enables
816+ // interception while the active route is remote.
818817 clipboard ports.ClipboardReader
819818 // Optional remote-learning seams.
820819 stateDir func() string
@@ -991,6 +990,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
991990 Dialer: sessionwire.NewClientDialer(localDialer()),
992991 Terminal: term.New(),
993992 Clock: clock.New(),
993+ Clipboard: deps.clipboard,
994994 Logger: log,
995995 RuntimeObserver: deps.runtimeObserver,
996996 AttachHandoff: handoff,
@@ -811,10 +811,9 @@ type runAttachDeps struct {
811 runClient runClientFunc811 runClient runClientFunc
812 createDetached func(context.Context, string) error812 createDetached func(context.Context, string) error
813 runtimeObserver ports.SerializedRuntimeObserver813 runtimeObserver ports.SerializedRuntimeObserver
814- // clipboard reads a clipboard image on a remote attach's Ctrl+V (see814+ // clipboard reads a clipboard image on a remote route's Ctrl+V.
815- // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).815+ // The client retains it across local-to-remote handoffs and only enables
816- // Only used for the remote-dialer branch below; local attaches never816+ // interception while the active route is remote.
817- // intercept Ctrl+V regardless of this field.
818 clipboard ports.ClipboardReader817 clipboard ports.ClipboardReader
819 // Optional remote-learning seams.818 // Optional remote-learning seams.
820 stateDir func() string819 stateDir func() string
@@ -991,6 +990,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
991 Dialer: sessionwire.NewClientDialer(localDialer()),990 Dialer: sessionwire.NewClientDialer(localDialer()),
992 Terminal: term.New(),991 Terminal: term.New(),
993 Clock: clock.New(),992 Clock: clock.New(),
993+ Clipboard: deps.clipboard,
994 Logger: log,994 Logger: log,
995 RuntimeObserver: deps.runtimeObserver,995 RuntimeObserver: deps.runtimeObserver,
996 AttachHandoff: handoff,996 AttachHandoff: handoff,
M internal/app/run_test.go +5 -4
@@ -1065,12 +1065,13 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
10651065 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
10661066 var gotDialer string
10671067 gotRemote := true
1068- gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1068+ var gotClipboard ports.ClipboardReader
1069+ clip := &fakeClipboardReader{}
10691070 factory := newRemoteDialerFactoryMock(t)
10701071 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
10711072 localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
10721073 remoteDialerFactory: factory.DialerForRemote,
1073- clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
1074+ clipboard: clip,
10741075 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
10751076 requireNamedClientDialer(t, ctx, deps.Dialer, "local")
10761077 gotDialer = "local"
@@ -1094,8 +1095,8 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
10941095 if gotRemote {
10951096 t.Fatal("local attach must pass remote=false to runClient")
10961097 }
1097- if gotClipboard != nil {
1098- t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
1098+ if gotClipboard != ports.ClipboardReader(clip) {
1099+ t.Fatalf("local attach must retain the ClipboardReader for later remote handoffs, got %#v", gotClipboard)
10991100 }
11001101 }
11011102
@@ -1065,12 +1065,13 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1065 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {1065 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1066 var gotDialer string1066 var gotDialer string
1067 gotRemote := true1067 gotRemote := true
1068- gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})1068+ var gotClipboard ports.ClipboardReader
1069+ clip := &fakeClipboardReader{}
1069 factory := newRemoteDialerFactoryMock(t)1070 factory := newRemoteDialerFactoryMock(t)
1070 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{1071 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
1071 localDialer: func() wire.Dialer { return namedDialer{name: "local"} },1072 localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
1072 remoteDialerFactory: factory.DialerForRemote,1073 remoteDialerFactory: factory.DialerForRemote,
1073- clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach1074+ clipboard: clip,
1074 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {1075 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
1075 requireNamedClientDialer(t, ctx, deps.Dialer, "local")1076 requireNamedClientDialer(t, ctx, deps.Dialer, "local")
1076 gotDialer = "local"1077 gotDialer = "local"
@@ -1094,8 +1095,8 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1094 if gotRemote {1095 if gotRemote {
1095 t.Fatal("local attach must pass remote=false to runClient")1096 t.Fatal("local attach must pass remote=false to runClient")
1096 }1097 }
1097- if gotClipboard != nil {1098+ if gotClipboard != ports.ClipboardReader(clip) {
1098- t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)1099+ t.Fatalf("local attach must retain the ClipboardReader for later remote handoffs, got %#v", gotClipboard)
1099 }1100 }
1100 }1101 }
1101 1102
M internal/ports/clipboard.go +2 -4
@@ -10,11 +10,9 @@ import (
1010 // forwarding whatever keystroke triggered the read.
1111 var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
1212
13-// ClipboardReader reads an image off the CLIENT-side system clipboard, for
13+// ClipboardReader reads an image off the client-side system clipboard for
1414 // forwarding to a remote session's focused pane. Implementations live in
15-// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
16-// see this interface. See
17-// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
15+// internal/adapters; use cases only see this interface.
1816 type ClipboardReader interface {
1917 // ReadImage returns the clipboard's image bytes and MIME type. It returns
2018 // ErrNoClipboardImage when the clipboard holds no image (or an
@@ -10,11 +10,9 @@ import (
10 // forwarding whatever keystroke triggered the read.10 // forwarding whatever keystroke triggered the read.
11 var ErrNoClipboardImage = errors.New("ports: no image on clipboard")11 var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
12 12
13-// ClipboardReader reads an image off the CLIENT-side system clipboard, for13+// ClipboardReader reads an image off the client-side system clipboard for
14 // forwarding to a remote session's focused pane. Implementations live in14 // forwarding to a remote session's focused pane. Implementations live in
15-// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only15+// internal/adapters; use cases only see this interface.
16-// see this interface. See
17-// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
18 type ClipboardReader interface {16 type ClipboardReader interface {
19 // ReadImage returns the clipboard's image bytes and MIME type. It returns17 // ReadImage returns the clipboard's image bytes and MIME type. It returns
20 // ErrNoClipboardImage when the clipboard holds no image (or an18 // ErrNoClipboardImage when the clipboard holds no image (or an

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 12

Tags 11

↑↓ moveenter openesc close