detached f71e65c

feat(client,daemon): transfer clipboard image to remote pane as temp-file path

On a remote attach, the stdin pump intercepts a bare Ctrl+V (0x16):
if wl-paste reports an image on the clipboard (png > jpeg > webp > gif
preference), it's read and sent as one MsgImagePush frame instead of
the keystroke; otherwise 0x16 is forwarded exactly as today. The
interceptor never reinterprets bytes while the paste coalescer is
buffering (mid-paste or holding a split marker prefix), so pasted text
containing 0x16 is never mistaken for a keypress. Local attaches are
unaffected — 0x16 always passes through so the local agent's own
clipboard handling applies.

The daemon writes MsgImagePush bytes to a 0600 temp file (path
recorded on the session and cleaned up at session end), then injects
the path into the focused pane through the normal write path, wrapped
in bracketed-paste markers iff the pane has DEC mode 2004 enabled.
Both sides independently cap payloads at 10 MiB.

Adds the internal/adapters/clipboard wl-paste adapter behind the new
ports.ClipboardReader interface. Includes the approved design doc.
commit
f71e65c81fc590bab55211762be488a6e74a747c
author
brice · · 2026-07-04 14:48 UTC
committer
brice · · 2026-07-04 14:51 UTC
parent
383c02c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1174 -18
A docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md +40 -0
@@ -0,0 +1,40 @@
1+# Clipboard image transfer (local → remote pane)
2+
3+Date: 2026-07-04 · Status: approved (approach A)
4+
5+## Problem
6+
7+When attached to a remote session, pasting an image for a TUI coding agent (pi, claude) is impossible: the agent's Ctrl+V handler reads the *remote* clipboard, which doesn't exist on a headless host (pi's chain — `wl-paste` → `xclip` → native — returns null in every branch without `WAYLAND_DISPLAY`/`DISPLAY`; no OSC 52 image path exists). The image lives on the laptop.
8+
9+## Approach
10+
11+Replicate the *end state* of pi's local Ctrl+V, which is: write the image to a temp file and insert the literal file path as text at the editor cursor (pi-mono `interactive-mode.ts:2536-2555`). vev does the same across the wire:
12+
13+1. **Client (remote attach only):** intercept `0x16` (Ctrl+V) on stdin. Run `wl-paste --list-types` (1s timeout). If an `image/*` type is present, pick by preference `png > jpeg > webp > gif` (same order as pi), read it with `wl-paste --type <mime> --no-newline`, and send one `ImagePush` frame. If no image / wl-paste missing / any error: forward `0x16` verbatim (today's behavior).
14+2. **Daemon:** on `ImagePush`, write the bytes to `os.TempDir()/vev-clip-<unixts>.<ext>` with mode 0600, remember the path on the session, and inject the path text into the focused pane's PTY — wrapped in `\x1b[200~…\x1b[201~` iff the pane's `Screen.BracketedPasteMode()` is true (first production use of the mode-2004 tracking), bare text otherwise. Injection goes through the same write path as regular input (`writeToPane`).
15+3. **Cleanup:** files recorded on the session are removed when the session ends (same lifecycle as the session teardown path). Best-effort; `/tmp` is the backstop.
16+
17+## Wire protocol
18+
19+- New client-originated message `MsgImagePush` (next free type in the 1–15 range) in `internal/ports`: `{ Mime string, Data []byte }`, hand-marshalled big-endian like the rest of `wire.go`.
20+- Bump `ProtocolVersion` (strict-equality negotiation; `Hello.Version` stays first field).
21+- Byte-for-byte golden tests + truncated-prefix / trailing-garbage invariants in `wire_test.go`, per repo protocol rules.
22+- Caps: client refuses images > 10 MiB (notifies via stderr/status); frame limit is 16 MiB so one frame always suffices. Daemon independently rejects > 10 MiB payloads (defense against old/foreign clients).
23+
24+## Scope decisions
25+
26+- **Remote attaches only.** On local attach, `0x16` passes through untouched — the agent reads the local clipboard natively and its own temp-file flow works.
27+- **Wayland only** on the client side (user's environment). The clipboard read sits behind a small client-side seam (interface in the client package or `ports` if it must cross layers) so `xclip`/OSC-52 backends can be added later without protocol changes.
28+- **No image conversion.** Bytes are transferred as-is; unsupported exotic formats are the agent's problem (pi converts BMP itself locally; that path simply isn't reachable here).
29+- **Known caveat:** while an image is on the local clipboard, Ctrl+V won't reach remote pane apps that bind it themselves (e.g. vim visual block). Text/empty clipboard → passthrough unchanged.
30+
31+## Testing
32+
33+- Client: fake clipboard runner (seam above) — image present → one ImagePush frame, no `0x16` forwarded; no image → `0x16` forwarded; oversized → refused + `0x16` not swallowed silently.
34+- Ports: golden wire tests as above.
35+- Daemon: ImagePush → file created 0600 with exact bytes, path injected with/without bracketed-paste wrapping depending on pane mode; session end removes the file; oversized rejected.
36+- Manual: end-to-end against the scratchpad harness (fake-ssh `_stdio` path) with a real PNG.
37+
38+## Out of scope
39+
40+Remote→local image transfer, multi-image batching, X11 client support, progress UI for large images.
@@ -0,0 +1,40 @@
1+# Clipboard image transfer (local → remote pane)
2+
3+Date: 2026-07-04 · Status: approved (approach A)
4+
5+## Problem
6+
7+When attached to a remote session, pasting an image for a TUI coding agent (pi, claude) is impossible: the agent's Ctrl+V handler reads the *remote* clipboard, which doesn't exist on a headless host (pi's chain — `wl-paste` → `xclip` → native — returns null in every branch without `WAYLAND_DISPLAY`/`DISPLAY`; no OSC 52 image path exists). The image lives on the laptop.
8+
9+## Approach
10+
11+Replicate the *end state* of pi's local Ctrl+V, which is: write the image to a temp file and insert the literal file path as text at the editor cursor (pi-mono `interactive-mode.ts:2536-2555`). vev does the same across the wire:
12+
13+1. **Client (remote attach only):** intercept `0x16` (Ctrl+V) on stdin. Run `wl-paste --list-types` (1s timeout). If an `image/*` type is present, pick by preference `png > jpeg > webp > gif` (same order as pi), read it with `wl-paste --type <mime> --no-newline`, and send one `ImagePush` frame. If no image / wl-paste missing / any error: forward `0x16` verbatim (today's behavior).
14+2. **Daemon:** on `ImagePush`, write the bytes to `os.TempDir()/vev-clip-<unixts>.<ext>` with mode 0600, remember the path on the session, and inject the path text into the focused pane's PTY — wrapped in `\x1b[200~…\x1b[201~` iff the pane's `Screen.BracketedPasteMode()` is true (first production use of the mode-2004 tracking), bare text otherwise. Injection goes through the same write path as regular input (`writeToPane`).
15+3. **Cleanup:** files recorded on the session are removed when the session ends (same lifecycle as the session teardown path). Best-effort; `/tmp` is the backstop.
16+
17+## Wire protocol
18+
19+- New client-originated message `MsgImagePush` (next free type in the 1–15 range) in `internal/ports`: `{ Mime string, Data []byte }`, hand-marshalled big-endian like the rest of `wire.go`.
20+- Bump `ProtocolVersion` (strict-equality negotiation; `Hello.Version` stays first field).
21+- Byte-for-byte golden tests + truncated-prefix / trailing-garbage invariants in `wire_test.go`, per repo protocol rules.
22+- Caps: client refuses images > 10 MiB (notifies via stderr/status); frame limit is 16 MiB so one frame always suffices. Daemon independently rejects > 10 MiB payloads (defense against old/foreign clients).
23+
24+## Scope decisions
25+
26+- **Remote attaches only.** On local attach, `0x16` passes through untouched — the agent reads the local clipboard natively and its own temp-file flow works.
27+- **Wayland only** on the client side (user's environment). The clipboard read sits behind a small client-side seam (interface in the client package or `ports` if it must cross layers) so `xclip`/OSC-52 backends can be added later without protocol changes.
28+- **No image conversion.** Bytes are transferred as-is; unsupported exotic formats are the agent's problem (pi converts BMP itself locally; that path simply isn't reachable here).
29+- **Known caveat:** while an image is on the local clipboard, Ctrl+V won't reach remote pane apps that bind it themselves (e.g. vim visual block). Text/empty clipboard → passthrough unchanged.
30+
31+## Testing
32+
33+- Client: fake clipboard runner (seam above) — image present → one ImagePush frame, no `0x16` forwarded; no image → `0x16` forwarded; oversized → refused + `0x16` not swallowed silently.
34+- Ports: golden wire tests as above.
35+- Daemon: ImagePush → file created 0600 with exact bytes, path injected with/without bracketed-paste wrapping depending on pane mode; session end removes the file; oversized rejected.
36+- Manual: end-to-end against the scratchpad harness (fake-ssh `_stdio` path) with a real PNG.
37+
38+## Out of scope
39+
40+Remote→local image transfer, multi-image batching, X11 client support, progress UI for large images.
A internal/adapters/clipboard/wlpaste.go +112 -0
@@ -0,0 +1,112 @@
1+// Package clipboard implements ports.ClipboardReader over Wayland's
2+// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
3+// attach forward a locally clipped image to the daemon (see
4+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
5+// xclip or OSC-52 backend could implement the same interface later without
6+// touching the wire protocol.
7+package clipboard
8+
9+import (
10+ "bytes"
11+ "context"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+// mimePreference orders candidate image MIME types the same way pi (the
20+// reference TUI agent whose local Ctrl+V behavior this replicates) does:
21+// png first, then the other common web/image formats.
22+var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
23+
24+// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
25+// missing wl-paste never stalls the stdin pump for long.
26+const listTypesTimeout = 1 * time.Second
27+
28+// defaultReadTimeout bounds the actual clipboard data read. Without this, a
29+// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
30+// which listTypesTimeout already covers) would stall the client's stdin pump
31+// indefinitely, since ReadImage is called with context.Background() from the
32+// interceptor. Generous for a read of up to the 10 MiB cap.
33+const defaultReadTimeout = 10 * time.Second
34+
35+// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
36+// holds no state and is safe for concurrent use; every call runs a fresh
37+// subprocess.
38+type WlPaste struct {
39+ // execCommand constructs the command to run; overridable in tests so
40+ // command construction can be verified without requiring wl-paste (or
41+ // even Wayland) to be installed.
42+ execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
43+ // readTimeout bounds the data-read command; zero means defaultReadTimeout
44+ // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
45+ // still gets a sane default). Tests needing a short deadline set it
46+ // explicitly.
47+ readTimeout time.Duration
48+}
49+
50+// New returns a WlPaste clipboard reader that shells out to the real
51+// wl-paste binary.
52+func New() *WlPaste {
53+ return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
54+}
55+
56+func (w *WlPaste) readTimeoutOrDefault() time.Duration {
57+ if w.readTimeout <= 0 {
58+ return defaultReadTimeout
59+ }
60+ return w.readTimeout
61+}
62+
63+// ReadImage implements ports.ClipboardReader. It lists the clipboard's
64+// offered MIME types, picks the best supported image type (if any), and
65+// reads it. Any failure (wl-paste missing, no image on the clipboard, a
66+// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
67+// uniformly fall back to forwarding the keystroke that triggered the read.
68+func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
69+ listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
70+ defer cancel()
71+
72+ listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
73+ out, err := listCmd.Output()
74+ if err != nil {
75+ return "", nil, ports.ErrNoClipboardImage
76+ }
77+
78+ mime := pickImageMime(string(out))
79+ if mime == "" {
80+ return "", nil, ports.ErrNoClipboardImage
81+ }
82+
83+ readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
84+ defer readCancel()
85+
86+ readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
87+ var buf bytes.Buffer
88+ readCmd.Stdout = &buf
89+ if err := readCmd.Run(); err != nil {
90+ return "", nil, ports.ErrNoClipboardImage
91+ }
92+ return mime, buf.Bytes(), nil
93+}
94+
95+// pickImageMime scans wl-paste --list-types output (one MIME type per line)
96+// and returns the first match in mimePreference order, or "" if the
97+// clipboard offers no supported image type.
98+func pickImageMime(listTypesOutput string) string {
99+ offered := make(map[string]bool)
100+ for _, line := range strings.Split(listTypesOutput, "\n") {
101+ line = strings.TrimSpace(line)
102+ if line != "" {
103+ offered[line] = true
104+ }
105+ }
106+ for _, mime := range mimePreference {
107+ if offered[mime] {
108+ return mime
109+ }
110+ }
111+ return ""
112+}
@@ -0,0 +1,112 @@
1+// Package clipboard implements ports.ClipboardReader over Wayland's
2+// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
3+// attach forward a locally clipped image to the daemon (see
4+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
5+// xclip or OSC-52 backend could implement the same interface later without
6+// touching the wire protocol.
7+package clipboard
8+
9+import (
10+ "bytes"
11+ "context"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+// mimePreference orders candidate image MIME types the same way pi (the
20+// reference TUI agent whose local Ctrl+V behavior this replicates) does:
21+// png first, then the other common web/image formats.
22+var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
23+
24+// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
25+// missing wl-paste never stalls the stdin pump for long.
26+const listTypesTimeout = 1 * time.Second
27+
28+// defaultReadTimeout bounds the actual clipboard data read. Without this, a
29+// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
30+// which listTypesTimeout already covers) would stall the client's stdin pump
31+// indefinitely, since ReadImage is called with context.Background() from the
32+// interceptor. Generous for a read of up to the 10 MiB cap.
33+const defaultReadTimeout = 10 * time.Second
34+
35+// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
36+// holds no state and is safe for concurrent use; every call runs a fresh
37+// subprocess.
38+type WlPaste struct {
39+ // execCommand constructs the command to run; overridable in tests so
40+ // command construction can be verified without requiring wl-paste (or
41+ // even Wayland) to be installed.
42+ execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
43+ // readTimeout bounds the data-read command; zero means defaultReadTimeout
44+ // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
45+ // still gets a sane default). Tests needing a short deadline set it
46+ // explicitly.
47+ readTimeout time.Duration
48+}
49+
50+// New returns a WlPaste clipboard reader that shells out to the real
51+// wl-paste binary.
52+func New() *WlPaste {
53+ return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
54+}
55+
56+func (w *WlPaste) readTimeoutOrDefault() time.Duration {
57+ if w.readTimeout <= 0 {
58+ return defaultReadTimeout
59+ }
60+ return w.readTimeout
61+}
62+
63+// ReadImage implements ports.ClipboardReader. It lists the clipboard's
64+// offered MIME types, picks the best supported image type (if any), and
65+// reads it. Any failure (wl-paste missing, no image on the clipboard, a
66+// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
67+// uniformly fall back to forwarding the keystroke that triggered the read.
68+func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
69+ listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
70+ defer cancel()
71+
72+ listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
73+ out, err := listCmd.Output()
74+ if err != nil {
75+ return "", nil, ports.ErrNoClipboardImage
76+ }
77+
78+ mime := pickImageMime(string(out))
79+ if mime == "" {
80+ return "", nil, ports.ErrNoClipboardImage
81+ }
82+
83+ readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
84+ defer readCancel()
85+
86+ readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
87+ var buf bytes.Buffer
88+ readCmd.Stdout = &buf
89+ if err := readCmd.Run(); err != nil {
90+ return "", nil, ports.ErrNoClipboardImage
91+ }
92+ return mime, buf.Bytes(), nil
93+}
94+
95+// pickImageMime scans wl-paste --list-types output (one MIME type per line)
96+// and returns the first match in mimePreference order, or "" if the
97+// clipboard offers no supported image type.
98+func pickImageMime(listTypesOutput string) string {
99+ offered := make(map[string]bool)
100+ for _, line := range strings.Split(listTypesOutput, "\n") {
101+ line = strings.TrimSpace(line)
102+ if line != "" {
103+ offered[line] = true
104+ }
105+ }
106+ for _, mime := range mimePreference {
107+ if offered[mime] {
108+ return mime
109+ }
110+ }
111+ return ""
112+}
A internal/adapters/clipboard/wlpaste_test.go +183 -0
@@ -0,0 +1,183 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "os/exec"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ output string
17+ want string
18+ }{
19+ {"no types", "", ""},
20+ {"text only", "text/plain\nUTF8_STRING\n", ""},
21+ {"png present", "text/plain\nimage/png\n", "image/png"},
22+ {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"},
23+ {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"},
24+ {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"},
25+ {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"},
26+ {"unsupported image type only", "image/bmp\n", ""},
27+ {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if got := pickImageMime(tt.output); got != tt.want {
32+ t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+// fakeExecCommand returns a substitute execCommand that never shells out to
39+// wl-paste itself: it inspects the requested command/args and swaps in a
40+// safe, universally available binary (printf) that produces the same
41+// stdout a real wl-paste invocation would, so command construction and the
42+// read pipeline can be verified without wl-paste installed.
43+func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) {
44+ t.Helper()
45+ var got [][]string
46+ fn := func(ctx context.Context, name string, args ...string) *exec.Cmd {
47+ call := append([]string{name}, args...)
48+ got = append(got, call)
49+ if name != "wl-paste" {
50+ return exec.CommandContext(ctx, "false")
51+ }
52+ if len(args) > 0 && args[0] == "--list-types" {
53+ return exec.CommandContext(ctx, "printf", "%s", listTypesOutput)
54+ }
55+ return exec.CommandContext(ctx, "printf", "%s", imageData)
56+ }
57+ return fn, &got
58+}
59+
60+func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) {
61+ execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES")
62+ w := &WlPaste{execCommand: execCommand}
63+
64+ mime, data, err := w.ReadImage(context.Background())
65+ if err != nil {
66+ t.Fatalf("ReadImage() error = %v", err)
67+ }
68+ if mime != "image/png" {
69+ t.Fatalf("mime = %q, want image/png", mime)
70+ }
71+ if string(data) != "PNGBYTES" {
72+ t.Fatalf("data = %q, want PNGBYTES", data)
73+ }
74+
75+ want := [][]string{
76+ {"wl-paste", "--list-types"},
77+ {"wl-paste", "--type", "image/png", "--no-newline"},
78+ }
79+ got := *calls
80+ if len(got) != len(want) {
81+ t.Fatalf("calls = %#v, want %#v", got, want)
82+ }
83+ for i := range want {
84+ if len(got[i]) != len(want[i]) {
85+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
86+ }
87+ for j := range want[i] {
88+ if got[i][j] != want[i][j] {
89+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
90+ }
91+ }
92+ }
93+}
94+
95+func TestReadImageNoImageOnClipboard(t *testing.T) {
96+ execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "")
97+ w := &WlPaste{execCommand: execCommand}
98+
99+ _, _, err := w.ReadImage(context.Background())
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
102+ }
103+}
104+
105+func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) {
106+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
107+ return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing
108+ }
109+ w := &WlPaste{execCommand: execCommand}
110+
111+ _, _, err := w.ReadImage(context.Background())
112+ if !errors.Is(err, ports.ErrNoClipboardImage) {
113+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
114+ }
115+}
116+
117+// TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data
118+// read (not just the --list-types probe) runs under a context carrying a
119+// deadline: called with context.Background() (as the client interceptor
120+// does), a wl-paste that hangs mid-read must not stall the caller forever.
121+func TestReadImageDataReadCommandHasDeadline(t *testing.T) {
122+ var readCtx context.Context
123+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
124+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
125+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
126+ }
127+ readCtx = ctx
128+ return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES")
129+ }
130+ w := &WlPaste{execCommand: execCommand}
131+
132+ _, _, err := w.ReadImage(context.Background())
133+ if err != nil {
134+ t.Fatalf("ReadImage() error = %v", err)
135+ }
136+ if readCtx == nil {
137+ t.Fatal("data read command was never constructed")
138+ }
139+ if _, ok := readCtx.Deadline(); !ok {
140+ t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()")
141+ }
142+}
143+
144+// TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while
145+// streaming the actual clipboard bytes (as opposed to during --list-types,
146+// covered by listTypesTimeout) is bounded by readTimeout instead of stalling
147+// the caller indefinitely.
148+func TestReadImageHungDataReadTimesOut(t *testing.T) {
149+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
150+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
151+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
152+ }
153+ return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read
154+ }
155+ w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond}
156+
157+ start := time.Now()
158+ _, _, err := w.ReadImage(context.Background())
159+ elapsed := time.Since(start)
160+
161+ if !errors.Is(err, ports.ErrNoClipboardImage) {
162+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
163+ }
164+ if elapsed > 2*time.Second {
165+ t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed)
166+ }
167+}
168+
169+// TestReadImageAgainstRealWlPaste is a best-effort integration check against
170+// the real binary; it skips itself when wl-paste (or a Wayland clipboard) is
171+// not available, which is the common case in CI/sandbox environments.
172+func TestReadImageAgainstRealWlPaste(t *testing.T) {
173+ if _, err := exec.LookPath("wl-paste"); err != nil {
174+ t.Skip("wl-paste not installed")
175+ }
176+ w := New()
177+ _, _, err := w.ReadImage(context.Background())
178+ // No clipboard image is a perfectly valid outcome in CI; just confirm
179+ // the call doesn't panic and errors are of the expected shape.
180+ if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) {
181+ t.Fatalf("ReadImage() unexpected error = %v", err)
182+ }
183+}
@@ -0,0 +1,183 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "os/exec"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ output string
17+ want string
18+ }{
19+ {"no types", "", ""},
20+ {"text only", "text/plain\nUTF8_STRING\n", ""},
21+ {"png present", "text/plain\nimage/png\n", "image/png"},
22+ {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"},
23+ {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"},
24+ {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"},
25+ {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"},
26+ {"unsupported image type only", "image/bmp\n", ""},
27+ {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if got := pickImageMime(tt.output); got != tt.want {
32+ t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+// fakeExecCommand returns a substitute execCommand that never shells out to
39+// wl-paste itself: it inspects the requested command/args and swaps in a
40+// safe, universally available binary (printf) that produces the same
41+// stdout a real wl-paste invocation would, so command construction and the
42+// read pipeline can be verified without wl-paste installed.
43+func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) {
44+ t.Helper()
45+ var got [][]string
46+ fn := func(ctx context.Context, name string, args ...string) *exec.Cmd {
47+ call := append([]string{name}, args...)
48+ got = append(got, call)
49+ if name != "wl-paste" {
50+ return exec.CommandContext(ctx, "false")
51+ }
52+ if len(args) > 0 && args[0] == "--list-types" {
53+ return exec.CommandContext(ctx, "printf", "%s", listTypesOutput)
54+ }
55+ return exec.CommandContext(ctx, "printf", "%s", imageData)
56+ }
57+ return fn, &got
58+}
59+
60+func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) {
61+ execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES")
62+ w := &WlPaste{execCommand: execCommand}
63+
64+ mime, data, err := w.ReadImage(context.Background())
65+ if err != nil {
66+ t.Fatalf("ReadImage() error = %v", err)
67+ }
68+ if mime != "image/png" {
69+ t.Fatalf("mime = %q, want image/png", mime)
70+ }
71+ if string(data) != "PNGBYTES" {
72+ t.Fatalf("data = %q, want PNGBYTES", data)
73+ }
74+
75+ want := [][]string{
76+ {"wl-paste", "--list-types"},
77+ {"wl-paste", "--type", "image/png", "--no-newline"},
78+ }
79+ got := *calls
80+ if len(got) != len(want) {
81+ t.Fatalf("calls = %#v, want %#v", got, want)
82+ }
83+ for i := range want {
84+ if len(got[i]) != len(want[i]) {
85+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
86+ }
87+ for j := range want[i] {
88+ if got[i][j] != want[i][j] {
89+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
90+ }
91+ }
92+ }
93+}
94+
95+func TestReadImageNoImageOnClipboard(t *testing.T) {
96+ execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "")
97+ w := &WlPaste{execCommand: execCommand}
98+
99+ _, _, err := w.ReadImage(context.Background())
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
102+ }
103+}
104+
105+func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) {
106+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
107+ return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing
108+ }
109+ w := &WlPaste{execCommand: execCommand}
110+
111+ _, _, err := w.ReadImage(context.Background())
112+ if !errors.Is(err, ports.ErrNoClipboardImage) {
113+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
114+ }
115+}
116+
117+// TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data
118+// read (not just the --list-types probe) runs under a context carrying a
119+// deadline: called with context.Background() (as the client interceptor
120+// does), a wl-paste that hangs mid-read must not stall the caller forever.
121+func TestReadImageDataReadCommandHasDeadline(t *testing.T) {
122+ var readCtx context.Context
123+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
124+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
125+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
126+ }
127+ readCtx = ctx
128+ return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES")
129+ }
130+ w := &WlPaste{execCommand: execCommand}
131+
132+ _, _, err := w.ReadImage(context.Background())
133+ if err != nil {
134+ t.Fatalf("ReadImage() error = %v", err)
135+ }
136+ if readCtx == nil {
137+ t.Fatal("data read command was never constructed")
138+ }
139+ if _, ok := readCtx.Deadline(); !ok {
140+ t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()")
141+ }
142+}
143+
144+// TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while
145+// streaming the actual clipboard bytes (as opposed to during --list-types,
146+// covered by listTypesTimeout) is bounded by readTimeout instead of stalling
147+// the caller indefinitely.
148+func TestReadImageHungDataReadTimesOut(t *testing.T) {
149+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
150+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
151+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
152+ }
153+ return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read
154+ }
155+ w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond}
156+
157+ start := time.Now()
158+ _, _, err := w.ReadImage(context.Background())
159+ elapsed := time.Since(start)
160+
161+ if !errors.Is(err, ports.ErrNoClipboardImage) {
162+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
163+ }
164+ if elapsed > 2*time.Second {
165+ t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed)
166+ }
167+}
168+
169+// TestReadImageAgainstRealWlPaste is a best-effort integration check against
170+// the real binary; it skips itself when wl-paste (or a Wayland clipboard) is
171+// not available, which is the common case in CI/sandbox environments.
172+func TestReadImageAgainstRealWlPaste(t *testing.T) {
173+ if _, err := exec.LookPath("wl-paste"); err != nil {
174+ t.Skip("wl-paste not installed")
175+ }
176+ w := New()
177+ _, _, err := w.ReadImage(context.Background())
178+ // No clipboard image is a perfectly valid outcome in CI; just confirm
179+ // the call doesn't panic and errors are of the expected shape.
180+ if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) {
181+ t.Fatalf("ReadImage() unexpected error = %v", err)
182+ }
183+}
M internal/app/run.go +10 -3
@@ -21,6 +21,7 @@ import (
2121 "text/tabwriter"
2222 "time"
2323
24+ "github.com/bnema/vev/internal/adapters/clipboard"
2425 "github.com/bnema/vev/internal/adapters/clock"
2526 "github.com/bnema/vev/internal/adapters/dgram"
2627 "github.com/bnema/vev/internal/adapters/ipc"
@@ -272,6 +273,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
272273 remoteDialer: defaultRemoteDialer,
273274 runClient: client.Run,
274275 createDetached: createDetachedLocalSession,
276+ clipboard: clipboard.New(),
275277 })
276278 }
277279
@@ -285,8 +287,13 @@ type runAttachDeps struct {
285287 attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
286288 localDialer func() ports.Dialer
287289 remoteDialer func(target, session string) ports.Dialer
288- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error
290+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader) error
289291 createDetached func(context.Context, string) error
292+ // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
293+ // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
294+ // Only used for the remote-dialer branch below; local attaches never
295+ // intercept Ctrl+V regardless of this field.
296+ clipboard ports.ClipboardReader
290297 }
291298
292299 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {
@@ -306,7 +313,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
306313 if remoteDialer == nil {
307314 remoteDialer = defaultRemoteDialer
308315 }
309- return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name)
316+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name, true, deps.clipboard)
310317 }
311318
312319 attachLocal := deps.attachLocal
@@ -316,7 +323,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
316323 localDialer = defaultLocalDialer
317324 }
318325 attachLocal = func(ctx context.Context, intent uint8, name string) error {
319- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name)
326+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil)
320327 }
321328 }
322329 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
@@ -21,6 +21,7 @@ import (
21 "text/tabwriter"21 "text/tabwriter"
22 "time"22 "time"
23 23
24+ "github.com/bnema/vev/internal/adapters/clipboard"
24 "github.com/bnema/vev/internal/adapters/clock"25 "github.com/bnema/vev/internal/adapters/clock"
25 "github.com/bnema/vev/internal/adapters/dgram"26 "github.com/bnema/vev/internal/adapters/dgram"
26 "github.com/bnema/vev/internal/adapters/ipc"27 "github.com/bnema/vev/internal/adapters/ipc"
@@ -272,6 +273,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
272 remoteDialer: defaultRemoteDialer,273 remoteDialer: defaultRemoteDialer,
273 runClient: client.Run,274 runClient: client.Run,
274 createDetached: createDetachedLocalSession,275 createDetached: createDetachedLocalSession,
276+ clipboard: clipboard.New(),
275 })277 })
276 }278 }
277 279
@@ -285,8 +287,13 @@ type runAttachDeps struct {
285 attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests287 attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
286 localDialer func() ports.Dialer288 localDialer func() ports.Dialer
287 remoteDialer func(target, session string) ports.Dialer289 remoteDialer func(target, session string) ports.Dialer
288- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error290+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader) error
289 createDetached func(context.Context, string) error291 createDetached func(context.Context, string) error
292+ // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
293+ // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
294+ // Only used for the remote-dialer branch below; local attaches never
295+ // intercept Ctrl+V regardless of this field.
296+ clipboard ports.ClipboardReader
290 }297 }
291 298
292 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {299 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {
@@ -306,7 +313,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
306 if remoteDialer == nil {313 if remoteDialer == nil {
307 remoteDialer = defaultRemoteDialer314 remoteDialer = defaultRemoteDialer
308 }315 }
309- return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name)316+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name, true, deps.clipboard)
310 }317 }
311 318
312 attachLocal := deps.attachLocal319 attachLocal := deps.attachLocal
@@ -316,7 +323,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
316 localDialer = defaultLocalDialer323 localDialer = defaultLocalDialer
317 }324 }
318 attachLocal = func(ctx context.Context, intent uint8, name string) error {325 attachLocal = func(ctx context.Context, intent uint8, name string) error {
319- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name)326+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil)
320 }327 }
321 }328 }
322 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{329 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
M internal/app/run_test.go +30 -2
@@ -393,15 +393,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
393393 return nil, errors.New("not used")
394394 }
395395
396+// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
397+// verify identity (that runAttachWithDeps threads the *same* reader through),
398+// never actually invoked in these wiring tests.
399+type fakeClipboardReader struct{ ports.ClipboardReader }
400+
396401 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
397402 var gotTarget, gotSession, gotDialer string
403+ var gotRemote bool
404+ var gotClipboard ports.ClipboardReader
405+ clip := &fakeClipboardReader{}
398406 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
399407 remoteDialer: func(target, session string) ports.Dialer {
400408 gotTarget, gotSession = target, session
401409 return namedDialer{name: "remote"}
402410 },
403- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {
411+ clipboard: clip,
412+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {
404413 gotDialer = d.(namedDialer).name
414+ gotRemote = remote
415+ gotClipboard = clipboard
405416 if intent != ports.IntentAttach || name != "work" {
406417 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
407418 }
@@ -414,14 +425,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
414425 if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
415426 t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
416427 }
428+ if !gotRemote {
429+ t.Fatal("remote attach must pass remote=true to runClient")
430+ }
431+ if gotClipboard != ports.ClipboardReader(clip) {
432+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
433+ }
417434 }
418435
419436 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
420437 var gotDialer string
438+ gotRemote := true
439+ gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
421440 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
422441 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
423- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {
442+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
443+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {
424444 gotDialer = d.(namedDialer).name
445+ gotRemote = remote
446+ gotClipboard = clipboard
425447 if intent != ports.IntentEphemeral || name != "" {
426448 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
427449 }
@@ -434,4 +456,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
434456 if gotDialer != "local" {
435457 t.Fatalf("local dialer = %q, want local", gotDialer)
436458 }
459+ if gotRemote {
460+ t.Fatal("local attach must pass remote=false to runClient")
461+ }
462+ if gotClipboard != nil {
463+ t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
464+ }
437465 }
@@ -393,15 +393,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
393 return nil, errors.New("not used")393 return nil, errors.New("not used")
394 }394 }
395 395
396+// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
397+// verify identity (that runAttachWithDeps threads the *same* reader through),
398+// never actually invoked in these wiring tests.
399+type fakeClipboardReader struct{ ports.ClipboardReader }
400+
396 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {401 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
397 var gotTarget, gotSession, gotDialer string402 var gotTarget, gotSession, gotDialer string
403+ var gotRemote bool
404+ var gotClipboard ports.ClipboardReader
405+ clip := &fakeClipboardReader{}
398 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{406 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
399 remoteDialer: func(target, session string) ports.Dialer {407 remoteDialer: func(target, session string) ports.Dialer {
400 gotTarget, gotSession = target, session408 gotTarget, gotSession = target, session
401 return namedDialer{name: "remote"}409 return namedDialer{name: "remote"}
402 },410 },
403- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {411+ clipboard: clip,
412+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {
404 gotDialer = d.(namedDialer).name413 gotDialer = d.(namedDialer).name
414+ gotRemote = remote
415+ gotClipboard = clipboard
405 if intent != ports.IntentAttach || name != "work" {416 if intent != ports.IntentAttach || name != "work" {
406 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)417 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
407 }418 }
@@ -414,14 +425,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
414 if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {425 if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
415 t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)426 t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
416 }427 }
428+ if !gotRemote {
429+ t.Fatal("remote attach must pass remote=true to runClient")
430+ }
431+ if gotClipboard != ports.ClipboardReader(clip) {
432+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
433+ }
417 }434 }
418 435
419 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {436 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
420 var gotDialer string437 var gotDialer string
438+ gotRemote := true
439+ gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
421 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{440 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
422 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },441 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
423- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {442+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
443+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {
424 gotDialer = d.(namedDialer).name444 gotDialer = d.(namedDialer).name
445+ gotRemote = remote
446+ gotClipboard = clipboard
425 if intent != ports.IntentEphemeral || name != "" {447 if intent != ports.IntentEphemeral || name != "" {
426 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)448 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
427 }449 }
@@ -434,4 +456,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
434 if gotDialer != "local" {456 if gotDialer != "local" {
435 t.Fatalf("local dialer = %q, want local", gotDialer)457 t.Fatalf("local dialer = %q, want local", gotDialer)
436 }458 }
459+ if gotRemote {
460+ t.Fatal("local attach must pass remote=false to runClient")
461+ }
462+ if gotClipboard != nil {
463+ t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
464+ }
437 }465 }
M internal/usecase/client/client.go +37 -7
@@ -119,7 +119,14 @@ const (
119119 // Run connects via dialer and runs the attach client. It owns the terminal
120120 // lifecycle above attach attempts so raw mode remains active while a live
121121 // client process redials a lost link.
122-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) {
122+//
123+// remote and clipboard together gate the clipboard-image-transfer feature
124+// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):
125+// Ctrl+V is only intercepted on a remote attach (remote true) with a
126+// ClipboardReader configured (clipboard non-nil) — local attaches forward
127+// 0x16 untouched so the locally running agent's own clipboard handling
128+// applies.
129+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) (retErr error) {
123130 log := slog.Default().With("component", "client")
124131 ms := milestones{}
125132
@@ -189,7 +196,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
189196 }
190197 ms.dialed = true
191198
192- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
199+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard)
193200 if result.welcomed {
194201 backoff = defaultReconnectBackoff.initial
195202 }
@@ -251,7 +258,7 @@ func shouldReconnect(err error) bool {
251258 //
252259 // It is kept as a compatibility wrapper for callers that still own dialing.
253260 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
254- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name)
261+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil)
255262 }
256263
257264 type singleTransportDialer struct{ transport ports.Transport }
@@ -266,7 +273,7 @@ type attachResult struct {
266273 err error
267274 }
268275
269-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {
276+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader) attachResult {
270277 // 1. Handshake: send Hello with our size and TERM.
271278 size, err := term.Size()
272279 if err != nil {
@@ -331,8 +338,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
331338 sendCh := make(chan ports.Frame, sendQueueDepth)
332339 sendErrCh := make(chan error, 1)
333340
341+ var clip ports.ClipboardReader
342+ if remote {
343+ clip = clipboard
344+ }
334345 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
335- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor)
346+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip)
336347 go runResize(loopCtx, term.ResizeEvents(), sendCh)
337348
338349 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -446,7 +457,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
446457 // exits. That is harmless here — Attach has already returned and restored
447458 // the terminal — and matches the standard pattern for stdin pumps; a
448459 // closable stdin duplicate could lift it later if ever needed.
449-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) {
460+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader) {
450461 buf := make([]byte, stdinBufSize)
451462 var scanner theme.Scanner
452463 var inputSeq uint64
@@ -472,6 +483,25 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
472483 send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
473484 })
474485 defer coalescer.Close()
486+
487+ // On a remote attach with a ClipboardReader configured, splice in the
488+ // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
489+ // becomes a clipboard image push instead of reaching the coalescer (and
490+ // from there the remote pane) as an ordinary keystroke.
491+ sink := coalescer.Scan
492+ if clipboard != nil {
493+ ci := &clipboardIntercept{
494+ coalescer: coalescer,
495+ reader: clipboard,
496+ log: slog.Default(),
497+ sendImage: func(mime string, data []byte) {
498+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})
499+ },
500+ next: coalescer.Scan,
501+ }
502+ sink = ci.Scan
503+ }
504+
475505 for {
476506 n, rerr := in.Read(buf)
477507 if n > 0 {
@@ -487,7 +517,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
487517 return
488518 }
489519 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
490- }, coalescer.Scan)
520+ }, sink)
491521 if !sendOK.Load() {
492522 return
493523 }
@@ -119,7 +119,14 @@ const (
119 // Run connects via dialer and runs the attach client. It owns the terminal119 // Run connects via dialer and runs the attach client. It owns the terminal
120 // lifecycle above attach attempts so raw mode remains active while a live120 // lifecycle above attach attempts so raw mode remains active while a live
121 // client process redials a lost link.121 // client process redials a lost link.
122-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) {122+//
123+// remote and clipboard together gate the clipboard-image-transfer feature
124+// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):
125+// Ctrl+V is only intercepted on a remote attach (remote true) with a
126+// ClipboardReader configured (clipboard non-nil) — local attaches forward
127+// 0x16 untouched so the locally running agent's own clipboard handling
128+// applies.
129+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) (retErr error) {
123 log := slog.Default().With("component", "client")130 log := slog.Default().With("component", "client")
124 ms := milestones{}131 ms := milestones{}
125 132
@@ -189,7 +196,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
189 }196 }
190 ms.dialed = true197 ms.dialed = true
191 198
192- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)199+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard)
193 if result.welcomed {200 if result.welcomed {
194 backoff = defaultReconnectBackoff.initial201 backoff = defaultReconnectBackoff.initial
195 }202 }
@@ -251,7 +258,7 @@ func shouldReconnect(err error) bool {
251 //258 //
252 // It is kept as a compatibility wrapper for callers that still own dialing.259 // It is kept as a compatibility wrapper for callers that still own dialing.
253 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {260 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
254- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name)261+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil)
255 }262 }
256 263
257 type singleTransportDialer struct{ transport ports.Transport }264 type singleTransportDialer struct{ transport ports.Transport }
@@ -266,7 +273,7 @@ type attachResult struct {
266 err error273 err error
267 }274 }
268 275
269-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {276+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader) attachResult {
270 // 1. Handshake: send Hello with our size and TERM.277 // 1. Handshake: send Hello with our size and TERM.
271 size, err := term.Size()278 size, err := term.Size()
272 if err != nil {279 if err != nil {
@@ -331,8 +338,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
331 sendCh := make(chan ports.Frame, sendQueueDepth)338 sendCh := make(chan ports.Frame, sendQueueDepth)
332 sendErrCh := make(chan error, 1)339 sendErrCh := make(chan error, 1)
333 340
341+ var clip ports.ClipboardReader
342+ if remote {
343+ clip = clipboard
344+ }
334 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)345 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
335- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor)346+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip)
336 go runResize(loopCtx, term.ResizeEvents(), sendCh)347 go runResize(loopCtx, term.ResizeEvents(), sendCh)
337 348
338 // 5. Output/main loop: the only goroutine that touches the terminal.349 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -446,7 +457,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
446 // exits. That is harmless here — Attach has already returned and restored457 // exits. That is harmless here — Attach has already returned and restored
447 // the terminal — and matches the standard pattern for stdin pumps; a458 // the terminal — and matches the standard pattern for stdin pumps; a
448 // closable stdin duplicate could lift it later if ever needed.459 // closable stdin duplicate could lift it later if ever needed.
449-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) {460+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader) {
450 buf := make([]byte, stdinBufSize)461 buf := make([]byte, stdinBufSize)
451 var scanner theme.Scanner462 var scanner theme.Scanner
452 var inputSeq uint64463 var inputSeq uint64
@@ -472,6 +483,25 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
472 send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})483 send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
473 })484 })
474 defer coalescer.Close()485 defer coalescer.Close()
486+
487+ // On a remote attach with a ClipboardReader configured, splice in the
488+ // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
489+ // becomes a clipboard image push instead of reaching the coalescer (and
490+ // from there the remote pane) as an ordinary keystroke.
491+ sink := coalescer.Scan
492+ if clipboard != nil {
493+ ci := &clipboardIntercept{
494+ coalescer: coalescer,
495+ reader: clipboard,
496+ log: slog.Default(),
497+ sendImage: func(mime string, data []byte) {
498+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})
499+ },
500+ next: coalescer.Scan,
501+ }
502+ sink = ci.Scan
503+ }
504+
475 for {505 for {
476 n, rerr := in.Read(buf)506 n, rerr := in.Read(buf)
477 if n > 0 {507 if n > 0 {
@@ -487,7 +517,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
487 return517 return
488 }518 }
489 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})519 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
490- }, coalescer.Scan)520+ }, sink)
491 if !sendOK.Load() {521 if !sendOK.Load() {
492 return522 return
493 }523 }
M internal/usecase/client/client_test.go +5 -5
@@ -565,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
565565 d := portsmocks.NewMockDialer(t)
566566 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
567567
568- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")
568+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)
569569 require.Error(t, err)
570570 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
571571 }
@@ -585,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
585585 d := portsmocks.NewMockDialer(t)
586586 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
587587
588- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main")
588+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil)
589589 require.Error(t, err)
590590 var pe *client.ProtocolError
591591 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -597,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
597597 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
598598 tm := portsmocks.NewMockTerminal(t)
599599
600- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")
600+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)
601601 require.ErrorIs(t, err, dialErr)
602602 }
603603
@@ -686,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
686686 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
687687 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
688688
689- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")
689+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)
690690 require.NoError(t, err)
691691 require.Equal(t, int32(3), d.calls.Load())
692692 require.Equal(t, int32(1), term.rawCount.Load())
@@ -713,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
713713 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
714714 d := &sequenceDialer{trs: []ports.Transport{tr}}
715715
716- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")
716+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)
717717 require.Error(t, err)
718718 var de *client.DetachedError
719719 require.True(t, errors.As(err, &de))
@@ -565,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
565 d := portsmocks.NewMockDialer(t)565 d := portsmocks.NewMockDialer(t)
566 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()566 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
567 567
568- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")568+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)
569 require.Error(t, err)569 require.Error(t, err)
570 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")570 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
571 }571 }
@@ -585,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
585 d := portsmocks.NewMockDialer(t)585 d := portsmocks.NewMockDialer(t)
586 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()586 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
587 587
588- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main")588+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil)
589 require.Error(t, err)589 require.Error(t, err)
590 var pe *client.ProtocolError590 var pe *client.ProtocolError
591 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)591 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -597,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
597 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()597 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
598 tm := portsmocks.NewMockTerminal(t)598 tm := portsmocks.NewMockTerminal(t)
599 599
600- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")600+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)
601 require.ErrorIs(t, err, dialErr)601 require.ErrorIs(t, err, dialErr)
602 }602 }
603 603
@@ -686,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
686 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}686 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
687 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}687 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
688 688
689- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")689+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)
690 require.NoError(t, err)690 require.NoError(t, err)
691 require.Equal(t, int32(3), d.calls.Load())691 require.Equal(t, int32(3), d.calls.Load())
692 require.Equal(t, int32(1), term.rawCount.Load())692 require.Equal(t, int32(1), term.rawCount.Load())
@@ -713,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
713 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}713 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
714 d := &sequenceDialer{trs: []ports.Transport{tr}}714 d := &sequenceDialer{trs: []ports.Transport{tr}}
715 715
716- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")716+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)
717 require.Error(t, err)717 require.Error(t, err)
718 var de *client.DetachedError718 var de *client.DetachedError
719 require.True(t, errors.As(err, &de))719 require.True(t, errors.As(err, &de))
A internal/usecase/client/clipboard_intercept.go +112 -0
@@ -0,0 +1,112 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// ctrlV is the byte a Ctrl+V keypress sends on the wire.
13+const ctrlV = 0x16
14+
15+// maxClipboardImagePush caps the image the client will send in one
16+// MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit
17+// means one frame always suffices under this cap.
18+const maxClipboardImagePush = 10 << 20 // 10 MiB
19+
20+// clipboardIntercept sits between the terminal's theme scanner and the paste
21+// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
22+// (0x16): each occurrence triggers a clipboard image read, sent as one
23+// MsgImagePush frame instead of the keystroke. Everything else — ordinary
24+// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
25+// text may legitimately contain it) — is forwarded to next unchanged, so it
26+// still gets the coalescer's marker-splitting treatment.
27+type clipboardIntercept struct {
28+ coalescer *pasteCoalescer
29+ reader ports.ClipboardReader
30+ log *slog.Logger
31+ sendImage func(mime string, data []byte)
32+ next func([]byte)
33+}
34+
35+// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
36+// can be used interchangeably as the scanner sink.
37+//
38+// It must never scan for Ctrl+V past the point where a bracketed paste
39+// starts (or might start): pasted text can legitimately contain 0x16, and
40+// the paste's opening marker can itself be split across two reads. So:
41+//
42+// 1. If the coalescer is already Buffering (mid-paste, or holding a
43+// trailing strict prefix of the opening marker from a previous call),
44+// the whole chunk is handed to next untouched.
45+// 2. Otherwise, this chunk is scanned only up to the first byte that either
46+// starts the opening marker in full or could be the start of a marker
47+// split across this chunk's end (pasteMarkerBoundary); everything from
48+// that point onward is handed to next as one piece, unscanned, letting
49+// the coalescer's own state decide what happens to it (and to any
50+// bytes trailing an in-chunk paste — see the package-level note below).
51+func (c *clipboardIntercept) Scan(data []byte) {
52+ if c.coalescer.Buffering() {
53+ c.next(data)
54+ return
55+ }
56+
57+ boundary := pasteMarkerBoundary(data)
58+ segment := data[:boundary]
59+ for len(segment) > 0 {
60+ idx := bytes.IndexByte(segment, ctrlV)
61+ if idx < 0 {
62+ c.next(segment)
63+ break
64+ }
65+ if idx > 0 {
66+ c.next(segment[:idx])
67+ }
68+ segment = segment[idx+1:]
69+ c.handleCtrlV()
70+ }
71+
72+ // Bytes at/after boundary are (the possible start of) a bracketed
73+ // paste: handed off whole, so any Ctrl+V trailing a paste that closes
74+ // within this same chunk is not intercepted either. In practice a real
75+ // keypress never arrives concatenated with paste bytes in one read, so
76+ // this is a deliberate, narrow trade-off in favor of never breaking
77+ // paste content.
78+ if tail := data[boundary:]; len(tail) > 0 {
79+ c.next(tail)
80+ }
81+}
82+
83+// pasteMarkerBoundary returns the index in data at or after which bytes must
84+// not be scanned for Ctrl+V because they start (or, at the very end of data,
85+// might be a split-across-reads prefix of) the bracketed-paste opening
86+// marker. It returns len(data) when neither is present.
87+func pasteMarkerBoundary(data []byte) int {
88+ if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
89+ return idx
90+ }
91+ if p := trailingOpenPrefixLen(data); p > 0 {
92+ return len(data) - p
93+ }
94+ return len(data)
95+}
96+
97+func (c *clipboardIntercept) handleCtrlV() {
98+ mime, data, err := c.reader.ReadImage(context.Background())
99+ if err != nil {
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ c.log.Warn("clipboard image read failed", "err", err)
102+ }
103+ c.next([]byte{ctrlV})
104+ return
105+ }
106+ if len(data) > maxClipboardImagePush {
107+ c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
108+ c.next([]byte{ctrlV})
109+ return
110+ }
111+ c.sendImage(mime, data)
112+}
@@ -0,0 +1,112 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// ctrlV is the byte a Ctrl+V keypress sends on the wire.
13+const ctrlV = 0x16
14+
15+// maxClipboardImagePush caps the image the client will send in one
16+// MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit
17+// means one frame always suffices under this cap.
18+const maxClipboardImagePush = 10 << 20 // 10 MiB
19+
20+// clipboardIntercept sits between the terminal's theme scanner and the paste
21+// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
22+// (0x16): each occurrence triggers a clipboard image read, sent as one
23+// MsgImagePush frame instead of the keystroke. Everything else — ordinary
24+// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
25+// text may legitimately contain it) — is forwarded to next unchanged, so it
26+// still gets the coalescer's marker-splitting treatment.
27+type clipboardIntercept struct {
28+ coalescer *pasteCoalescer
29+ reader ports.ClipboardReader
30+ log *slog.Logger
31+ sendImage func(mime string, data []byte)
32+ next func([]byte)
33+}
34+
35+// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
36+// can be used interchangeably as the scanner sink.
37+//
38+// It must never scan for Ctrl+V past the point where a bracketed paste
39+// starts (or might start): pasted text can legitimately contain 0x16, and
40+// the paste's opening marker can itself be split across two reads. So:
41+//
42+// 1. If the coalescer is already Buffering (mid-paste, or holding a
43+// trailing strict prefix of the opening marker from a previous call),
44+// the whole chunk is handed to next untouched.
45+// 2. Otherwise, this chunk is scanned only up to the first byte that either
46+// starts the opening marker in full or could be the start of a marker
47+// split across this chunk's end (pasteMarkerBoundary); everything from
48+// that point onward is handed to next as one piece, unscanned, letting
49+// the coalescer's own state decide what happens to it (and to any
50+// bytes trailing an in-chunk paste — see the package-level note below).
51+func (c *clipboardIntercept) Scan(data []byte) {
52+ if c.coalescer.Buffering() {
53+ c.next(data)
54+ return
55+ }
56+
57+ boundary := pasteMarkerBoundary(data)
58+ segment := data[:boundary]
59+ for len(segment) > 0 {
60+ idx := bytes.IndexByte(segment, ctrlV)
61+ if idx < 0 {
62+ c.next(segment)
63+ break
64+ }
65+ if idx > 0 {
66+ c.next(segment[:idx])
67+ }
68+ segment = segment[idx+1:]
69+ c.handleCtrlV()
70+ }
71+
72+ // Bytes at/after boundary are (the possible start of) a bracketed
73+ // paste: handed off whole, so any Ctrl+V trailing a paste that closes
74+ // within this same chunk is not intercepted either. In practice a real
75+ // keypress never arrives concatenated with paste bytes in one read, so
76+ // this is a deliberate, narrow trade-off in favor of never breaking
77+ // paste content.
78+ if tail := data[boundary:]; len(tail) > 0 {
79+ c.next(tail)
80+ }
81+}
82+
83+// pasteMarkerBoundary returns the index in data at or after which bytes must
84+// not be scanned for Ctrl+V because they start (or, at the very end of data,
85+// might be a split-across-reads prefix of) the bracketed-paste opening
86+// marker. It returns len(data) when neither is present.
87+func pasteMarkerBoundary(data []byte) int {
88+ if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
89+ return idx
90+ }
91+ if p := trailingOpenPrefixLen(data); p > 0 {
92+ return len(data) - p
93+ }
94+ return len(data)
95+}
96+
97+func (c *clipboardIntercept) handleCtrlV() {
98+ mime, data, err := c.reader.ReadImage(context.Background())
99+ if err != nil {
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ c.log.Warn("clipboard image read failed", "err", err)
102+ }
103+ c.next([]byte{ctrlV})
104+ return
105+ }
106+ if len(data) > maxClipboardImagePush {
107+ c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
108+ c.next([]byte{ctrlV})
109+ return
110+ }
111+ c.sendImage(mime, data)
112+}
A internal/usecase/client/clipboard_intercept_internal_test.go +160 -0
@@ -0,0 +1,160 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// ciFakeReader is a directly-controllable ports.ClipboardReader for
15+// white-box interceptor tests (this file lives in package client, alongside
16+// paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly).
17+type ciFakeReader struct {
18+ mime string
19+ data []byte
20+ err error
21+ n int
22+}
23+
24+func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) {
25+ r.n++
26+ return r.mime, r.data, r.err
27+}
28+
29+func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) {
30+ clk := &pcFakeClock{}
31+ col := &pcCollector{}
32+ pc := newPasteCoalescer(clk, col.emit)
33+ var images []ports.ImagePush
34+ ci := &clipboardIntercept{
35+ coalescer: pc,
36+ reader: reader,
37+ log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})),
38+ sendImage: func(mime string, data []byte) {
39+ images = append(images, ports.ImagePush{Mime: mime, Data: data})
40+ },
41+ next: pc.Scan,
42+ }
43+ return ci, pc, clk, col, &images
44+}
45+
46+func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) {
47+ reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")}
48+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
49+ defer pc.Close()
50+
51+ ci.Scan([]byte("a"))
52+ ci.Scan([]byte{ctrlV})
53+ ci.Scan([]byte("b"))
54+
55+ require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V")
56+ require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images)
57+ require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane")
58+}
59+
60+func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) {
61+ reader := &ciFakeReader{err: ports.ErrNoClipboardImage}
62+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
63+ defer pc.Close()
64+
65+ ci.Scan([]byte("a\x16b"))
66+
67+ require.Empty(t, *images)
68+ require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot())
69+}
70+
71+func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
72+ reader := &ciFakeReader{err: errors.New("boom")}
73+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
74+ defer pc.Close()
75+
76+ ci.Scan([]byte{ctrlV})
77+
78+ require.Empty(t, *images)
79+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80+}
81+
82+func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83+ huge := make([]byte, maxClipboardImagePush+1)
84+ reader := &ciFakeReader{mime: "image/png", data: huge}
85+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
86+ defer pc.Close()
87+
88+ ci.Scan([]byte{ctrlV})
89+
90+ require.Empty(t, *images, "an oversized image must not be sent")
91+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it")
92+}
93+
94+func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) {
95+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
96+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
97+ defer pc.Close()
98+
99+ ci.Scan([]byte("a\x16b\x16c"))
100+
101+ require.Equal(t, 2, reader.n)
102+ require.Len(t, *images, 2)
103+ require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot())
104+}
105+
106+func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) {
107+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
108+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
109+ defer pc.Close()
110+
111+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
112+ ci.Scan(paste)
113+
114+ require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste")
115+ require.Empty(t, *images)
116+ require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical")
117+}
118+
119+func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) {
120+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
121+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
122+ defer pc.Close()
123+
124+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
125+ for s := 1; s < len(paste); s++ {
126+ t.Run("", func(t *testing.T) {
127+ reader.n = 0
128+ *images = nil
129+ col.emits = nil
130+ ci.Scan(paste[:s])
131+ ci.Scan(paste[s:])
132+ require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s)
133+ require.Empty(t, *images)
134+ require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s)
135+ })
136+ }
137+}
138+
139+func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) {
140+ // A trailing lone ESC (a strict prefix of the open marker) is held by
141+ // the coalescer as "pending" without yet flipping Buffering() via the
142+ // old (buffering-only) definition; Buffering() must also cover pending
143+ // so a Ctrl+V arriving before the flush timer fires is never
144+ // misinterpreted mid-marker.
145+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
146+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
147+ defer pc.Close()
148+
149+ ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes
150+ require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering")
151+
152+ ci.Scan([]byte{ctrlV})
153+
154+ require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending")
155+ require.Empty(t, *images)
156+ // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the
157+ // coalescer, which does not find a marker in "\x1bfamiliar" and emits
158+ // it as plain text once no further marker bytes follow.
159+ require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot())
160+}
@@ -0,0 +1,160 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// ciFakeReader is a directly-controllable ports.ClipboardReader for
15+// white-box interceptor tests (this file lives in package client, alongside
16+// paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly).
17+type ciFakeReader struct {
18+ mime string
19+ data []byte
20+ err error
21+ n int
22+}
23+
24+func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) {
25+ r.n++
26+ return r.mime, r.data, r.err
27+}
28+
29+func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) {
30+ clk := &pcFakeClock{}
31+ col := &pcCollector{}
32+ pc := newPasteCoalescer(clk, col.emit)
33+ var images []ports.ImagePush
34+ ci := &clipboardIntercept{
35+ coalescer: pc,
36+ reader: reader,
37+ log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})),
38+ sendImage: func(mime string, data []byte) {
39+ images = append(images, ports.ImagePush{Mime: mime, Data: data})
40+ },
41+ next: pc.Scan,
42+ }
43+ return ci, pc, clk, col, &images
44+}
45+
46+func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) {
47+ reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")}
48+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
49+ defer pc.Close()
50+
51+ ci.Scan([]byte("a"))
52+ ci.Scan([]byte{ctrlV})
53+ ci.Scan([]byte("b"))
54+
55+ require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V")
56+ require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images)
57+ require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane")
58+}
59+
60+func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) {
61+ reader := &ciFakeReader{err: ports.ErrNoClipboardImage}
62+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
63+ defer pc.Close()
64+
65+ ci.Scan([]byte("a\x16b"))
66+
67+ require.Empty(t, *images)
68+ require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot())
69+}
70+
71+func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
72+ reader := &ciFakeReader{err: errors.New("boom")}
73+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
74+ defer pc.Close()
75+
76+ ci.Scan([]byte{ctrlV})
77+
78+ require.Empty(t, *images)
79+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80+}
81+
82+func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83+ huge := make([]byte, maxClipboardImagePush+1)
84+ reader := &ciFakeReader{mime: "image/png", data: huge}
85+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
86+ defer pc.Close()
87+
88+ ci.Scan([]byte{ctrlV})
89+
90+ require.Empty(t, *images, "an oversized image must not be sent")
91+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it")
92+}
93+
94+func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) {
95+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
96+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
97+ defer pc.Close()
98+
99+ ci.Scan([]byte("a\x16b\x16c"))
100+
101+ require.Equal(t, 2, reader.n)
102+ require.Len(t, *images, 2)
103+ require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot())
104+}
105+
106+func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) {
107+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
108+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
109+ defer pc.Close()
110+
111+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
112+ ci.Scan(paste)
113+
114+ require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste")
115+ require.Empty(t, *images)
116+ require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical")
117+}
118+
119+func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) {
120+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
121+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
122+ defer pc.Close()
123+
124+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
125+ for s := 1; s < len(paste); s++ {
126+ t.Run("", func(t *testing.T) {
127+ reader.n = 0
128+ *images = nil
129+ col.emits = nil
130+ ci.Scan(paste[:s])
131+ ci.Scan(paste[s:])
132+ require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s)
133+ require.Empty(t, *images)
134+ require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s)
135+ })
136+ }
137+}
138+
139+func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) {
140+ // A trailing lone ESC (a strict prefix of the open marker) is held by
141+ // the coalescer as "pending" without yet flipping Buffering() via the
142+ // old (buffering-only) definition; Buffering() must also cover pending
143+ // so a Ctrl+V arriving before the flush timer fires is never
144+ // misinterpreted mid-marker.
145+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
146+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
147+ defer pc.Close()
148+
149+ ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes
150+ require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering")
151+
152+ ci.Scan([]byte{ctrlV})
153+
154+ require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending")
155+ require.Empty(t, *images)
156+ // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the
157+ // coalescer, which does not find a marker in "\x1bfamiliar" and emits
158+ // it as plain text once no further marker bytes follow.
159+ require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot())
160+}
A internal/usecase/client/clipboard_intercept_test.go +175 -0
@@ -0,0 +1,175 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/client"
17+)
18+
19+// clipboardRunTestDeps wires a Run() attempt with an input reader that
20+// delivers data then blocks (so the pump stays parked until detach), a
21+// transport that captures every MsgInput/MsgImagePush send, and a Recv that
22+// answers Welcome then, once triggered, Detached — mirroring the pattern used
23+// by the OSC-color-response test elsewhere in this package.
24+func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
25+ t.Helper()
26+ var out bytes.Buffer
27+ var restoreCount int32
28+ resizeCh := make(chan domain.Size)
29+ input := newOneShotBlockingReader(stdin)
30+ defer input.unblock()
31+
32+ tm := portsmocks.NewMockTerminal(t)
33+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
34+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
35+ tm.EXPECT().In().Return(input).Maybe()
36+ tm.EXPECT().Out().Return(&out).Maybe()
37+ tm.EXPECT().Flush().Return(nil).Maybe()
38+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
39+
40+ gotInput = make(chan []byte, 8)
41+ gotImage = make(chan ports.ImagePush, 1)
42+ allowDetach := make(chan struct{})
43+
44+ tr := portsmocks.NewMockTransport(t)
45+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
46+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
47+ in, err := ports.UnmarshalInput(f.Payload)
48+ require.NoError(t, err)
49+ gotInput <- append([]byte(nil), in.Data...)
50+ if bytes.IndexByte(in.Data, triggerByte) >= 0 {
51+ closeOnce(allowDetach)
52+ }
53+ return nil
54+ }).Maybe()
55+ tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
56+ ip, err := ports.UnmarshalImagePush(f.Payload)
57+ require.NoError(t, err)
58+ gotImage <- ip
59+ return nil
60+ }).Maybe()
61+
62+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
63+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
64+ recvCh := make(chan recvItem, 1)
65+ recvCh <- recvItem{f: welcome}
66+ closed := make(chan struct{})
67+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
68+ select {
69+ case it := <-recvCh:
70+ return it.f, it.err
71+ case <-allowDetach:
72+ select {
73+ case <-closed:
74+ return ports.Frame{}, io.EOF
75+ default:
76+ close(closed)
77+ return detached, nil
78+ }
79+ case <-closed:
80+ return ports.Frame{}, io.EOF
81+ }
82+ }).Maybe()
83+ tr.EXPECT().Close().Return(nil).Once()
84+
85+ d := portsmocks.NewMockDialer(t)
86+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
87+
88+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip)
89+ require.NoError(t, err)
90+ return gotInput, gotImage
91+}
92+
93+func closeOnce(ch chan struct{}) {
94+ select {
95+ case <-ch:
96+ default:
97+ close(ch)
98+ }
99+}
100+
101+func drainInput(gotInput chan []byte) []byte {
102+ var b []byte
103+ for {
104+ select {
105+ case chunk := <-gotInput:
106+ b = append(b, chunk...)
107+ default:
108+ return b
109+ }
110+ }
111+}
112+
113+func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
114+ clip := portsmocks.NewMockClipboardReader(t)
115+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
116+
117+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
118+
119+ select {
120+ case ip := <-gotImage:
121+ require.Equal(t, "image/png", ip.Mime)
122+ require.Equal(t, []byte("PNGDATA"), ip.Data)
123+ case <-time.After(2 * time.Second):
124+ t.Fatal("MsgImagePush was not sent")
125+ }
126+ require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
127+}
128+
129+func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
130+ clip := portsmocks.NewMockClipboardReader(t)
131+ clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
132+
133+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
134+
135+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
136+}
137+
138+func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
139+ huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap
140+ clip := portsmocks.NewMockClipboardReader(t)
141+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
142+
143+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
144+
145+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
146+}
147+
148+func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
149+ // A MockClipboardReader with no .EXPECT() set fails the test if
150+ // ReadImage is ever called — which must never happen for a 0x16 that
151+ // arrives as part of pasted content, even when the whole paste (open
152+ // marker, content, close marker) lands in a single read alongside it.
153+ clip := portsmocks.NewMockClipboardReader(t)
154+
155+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
156+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
157+
158+ require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
159+ select {
160+ case ip := <-gotImage:
161+ t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
162+ default:
163+ }
164+}
165+
166+func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
167+ // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
168+ // is ever called, which is exactly what a local (non-remote) attach must
169+ // never do.
170+ clip := portsmocks.NewMockClipboardReader(t)
171+
172+ gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
173+
174+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
175+}
@@ -0,0 +1,175 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/client"
17+)
18+
19+// clipboardRunTestDeps wires a Run() attempt with an input reader that
20+// delivers data then blocks (so the pump stays parked until detach), a
21+// transport that captures every MsgInput/MsgImagePush send, and a Recv that
22+// answers Welcome then, once triggered, Detached — mirroring the pattern used
23+// by the OSC-color-response test elsewhere in this package.
24+func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
25+ t.Helper()
26+ var out bytes.Buffer
27+ var restoreCount int32
28+ resizeCh := make(chan domain.Size)
29+ input := newOneShotBlockingReader(stdin)
30+ defer input.unblock()
31+
32+ tm := portsmocks.NewMockTerminal(t)
33+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
34+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
35+ tm.EXPECT().In().Return(input).Maybe()
36+ tm.EXPECT().Out().Return(&out).Maybe()
37+ tm.EXPECT().Flush().Return(nil).Maybe()
38+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
39+
40+ gotInput = make(chan []byte, 8)
41+ gotImage = make(chan ports.ImagePush, 1)
42+ allowDetach := make(chan struct{})
43+
44+ tr := portsmocks.NewMockTransport(t)
45+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
46+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
47+ in, err := ports.UnmarshalInput(f.Payload)
48+ require.NoError(t, err)
49+ gotInput <- append([]byte(nil), in.Data...)
50+ if bytes.IndexByte(in.Data, triggerByte) >= 0 {
51+ closeOnce(allowDetach)
52+ }
53+ return nil
54+ }).Maybe()
55+ tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
56+ ip, err := ports.UnmarshalImagePush(f.Payload)
57+ require.NoError(t, err)
58+ gotImage <- ip
59+ return nil
60+ }).Maybe()
61+
62+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
63+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
64+ recvCh := make(chan recvItem, 1)
65+ recvCh <- recvItem{f: welcome}
66+ closed := make(chan struct{})
67+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
68+ select {
69+ case it := <-recvCh:
70+ return it.f, it.err
71+ case <-allowDetach:
72+ select {
73+ case <-closed:
74+ return ports.Frame{}, io.EOF
75+ default:
76+ close(closed)
77+ return detached, nil
78+ }
79+ case <-closed:
80+ return ports.Frame{}, io.EOF
81+ }
82+ }).Maybe()
83+ tr.EXPECT().Close().Return(nil).Once()
84+
85+ d := portsmocks.NewMockDialer(t)
86+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
87+
88+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip)
89+ require.NoError(t, err)
90+ return gotInput, gotImage
91+}
92+
93+func closeOnce(ch chan struct{}) {
94+ select {
95+ case <-ch:
96+ default:
97+ close(ch)
98+ }
99+}
100+
101+func drainInput(gotInput chan []byte) []byte {
102+ var b []byte
103+ for {
104+ select {
105+ case chunk := <-gotInput:
106+ b = append(b, chunk...)
107+ default:
108+ return b
109+ }
110+ }
111+}
112+
113+func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
114+ clip := portsmocks.NewMockClipboardReader(t)
115+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
116+
117+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
118+
119+ select {
120+ case ip := <-gotImage:
121+ require.Equal(t, "image/png", ip.Mime)
122+ require.Equal(t, []byte("PNGDATA"), ip.Data)
123+ case <-time.After(2 * time.Second):
124+ t.Fatal("MsgImagePush was not sent")
125+ }
126+ require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
127+}
128+
129+func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
130+ clip := portsmocks.NewMockClipboardReader(t)
131+ clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
132+
133+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
134+
135+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
136+}
137+
138+func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
139+ huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap
140+ clip := portsmocks.NewMockClipboardReader(t)
141+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
142+
143+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
144+
145+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
146+}
147+
148+func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
149+ // A MockClipboardReader with no .EXPECT() set fails the test if
150+ // ReadImage is ever called — which must never happen for a 0x16 that
151+ // arrives as part of pasted content, even when the whole paste (open
152+ // marker, content, close marker) lands in a single read alongside it.
153+ clip := portsmocks.NewMockClipboardReader(t)
154+
155+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
156+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
157+
158+ require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
159+ select {
160+ case ip := <-gotImage:
161+ t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
162+ default:
163+ }
164+}
165+
166+func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
167+ // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
168+ // is ever called, which is exactly what a local (non-remote) attach must
169+ // never do.
170+ clip := portsmocks.NewMockClipboardReader(t)
171+
172+ gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
173+
174+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
175+}
M internal/usecase/client/paste_coalescer.go +14 -0
@@ -144,6 +144,20 @@ func (c *pasteCoalescer) resetBuffering() {
144144 c.stopSafetyTimer()
145145 }
146146
147+// Buffering reports whether it is currently unsafe to reinterpret incoming
148+// bytes as ordinary keystrokes: either a bracketed paste is being
149+// accumulated, or a trailing strict prefix of the opening marker is being
150+// held across a read boundary (pending, awaiting either completion into a
151+// real paste or the flush timer proving it was not one). The clipboard
152+// Ctrl+V interceptor uses this to avoid treating bytes that are, or might
153+// turn out to be, part of an in-flight paste as a standalone keystroke —
154+// pasted text may legitimately contain 0x16.
155+func (c *pasteCoalescer) Buffering() bool {
156+ c.mu.Lock()
157+ defer c.mu.Unlock()
158+ return c.buffering || len(c.pending) > 0
159+}
160+
147161 // Close stops any pending timers and their goroutines. Held bytes are dropped:
148162 // Close runs only as the stdin pump unwinds on detach, when there is nowhere
149163 // left to deliver them.
@@ -144,6 +144,20 @@ func (c *pasteCoalescer) resetBuffering() {
144 c.stopSafetyTimer()144 c.stopSafetyTimer()
145 }145 }
146 146
147+// Buffering reports whether it is currently unsafe to reinterpret incoming
148+// bytes as ordinary keystrokes: either a bracketed paste is being
149+// accumulated, or a trailing strict prefix of the opening marker is being
150+// held across a read boundary (pending, awaiting either completion into a
151+// real paste or the flush timer proving it was not one). The clipboard
152+// Ctrl+V interceptor uses this to avoid treating bytes that are, or might
153+// turn out to be, part of an in-flight paste as a standalone keystroke —
154+// pasted text may legitimately contain 0x16.
155+func (c *pasteCoalescer) Buffering() bool {
156+ c.mu.Lock()
157+ defer c.mu.Unlock()
158+ return c.buffering || len(c.pending) > 0
159+}
160+
147 // Close stops any pending timers and their goroutines. Held bytes are dropped:161 // Close stops any pending timers and their goroutines. Held bytes are dropped:
148 // Close runs only as the stdin pump unwinds on detach, when there is nowhere162 // Close runs only as the stdin pump unwinds on detach, when there is nowhere
149 // left to deliver them.163 // left to deliver them.
M internal/usecase/daemon/client.go +4 -0
@@ -481,6 +481,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
481481 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
482482 d.applyTheme(sess, ac, th)
483483 }
484+ case ports.MsgImagePush:
485+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
486+ d.handleImagePush(sess, ip)
487+ }
484488 case ports.MsgDetach:
485489 d.clientGone(sess, ac, tr, true)
486490 return
@@ -481,6 +481,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
481 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {481 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
482 d.applyTheme(sess, ac, th)482 d.applyTheme(sess, ac, th)
483 }483 }
484+ case ports.MsgImagePush:
485+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
486+ d.handleImagePush(sess, ip)
487+ }
484 case ports.MsgDetach:488 case ports.MsgDetach:
485 d.clientGone(sess, ac, tr, true)489 d.clientGone(sess, ac, tr, true)
486 return490 return
A internal/usecase/daemon/clipboard.go +116 -0
@@ -0,0 +1,116 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+package daemon
5+
6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// maxImagePushSize independently caps an accepted MsgImagePush payload,
15+// defending against an old or foreign client even though the client is
16+// expected to enforce the same cap before sending
17+// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
18+const maxImagePushSize = 10 << 20 // 10 MiB
19+
20+// Bracketed-paste markers used to wrap the injected clip path when the
21+// focused pane has DEC private mode 2004 enabled. Mirrors the client's
22+// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
23+// separate copy since the daemon has no dependency on the client package.
24+var (
25+ clipPasteOpenMarker = []byte("\x1b[200~")
26+ clipPasteCloseMarker = []byte("\x1b[201~")
27+)
28+
29+// handleImagePush writes an ImagePush's bytes to a temp file and injects the
30+// file's path into the session's focused pane, as if typed/pasted there.
31+// Oversized or empty payloads are rejected without panicking. A write
32+// failure is logged and the push is dropped: the client has already
33+// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
34+// to re-trigger from here.
35+func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
36+ if len(ip.Data) == 0 {
37+ return
38+ }
39+ if len(ip.Data) > maxImagePushSize {
40+ d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
41+ return
42+ }
43+ path, err := d.writeClipboardImage(sess, ip)
44+ if err != nil {
45+ d.log.Error("writing clipboard image failed", "err", err)
46+ return
47+ }
48+ d.injectClipboardPath(sess, path)
49+}
50+
51+// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
52+// the path on sess for best-effort cleanup at session end (killSession).
53+func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
54+ dir := d.tempDir
55+ if dir == "" {
56+ dir = os.TempDir()
57+ }
58+ name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime))
59+ path := filepath.Join(dir, name)
60+ if err := os.WriteFile(path, ip.Data, 0o600); err != nil {
61+ return "", err
62+ }
63+ sess.mu.Lock()
64+ sess.clipFiles = append(sess.clipFiles, path)
65+ sess.mu.Unlock()
66+ return path, nil
67+}
68+
69+// injectClipboardPath writes path's text into sess's focused pane, through
70+// the same PTY write path as ordinary input, wrapped in bracketed-paste
71+// markers iff the pane's Screen reports bracketed-paste mode enabled.
72+func (d *Daemon) injectClipboardPath(sess *session, path string) {
73+ tb := sess.activeTab()
74+ if tb == nil {
75+ return
76+ }
77+ tb.mu.Lock()
78+ p := tb.focusedPane()
79+ tb.mu.Unlock()
80+ if p == nil {
81+ return
82+ }
83+
84+ p.mu.Lock()
85+ bracketed := p.screen.BracketedPasteMode()
86+ p.mu.Unlock()
87+
88+ data := []byte(path)
89+ if bracketed {
90+ wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
91+ wrapped = append(wrapped, clipPasteOpenMarker...)
92+ wrapped = append(wrapped, data...)
93+ wrapped = append(wrapped, clipPasteCloseMarker...)
94+ data = wrapped
95+ }
96+ d.writeToPane(sess, p, data)
97+}
98+
99+// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
100+// types fall back to "bin" rather than rejecting the push: the daemon copies
101+// bytes as-is regardless (see the design doc's "no image conversion" scope
102+// decision).
103+func clipboardExt(mime string) string {
104+ switch mime {
105+ case "image/png":
106+ return "png"
107+ case "image/jpeg":
108+ return "jpg"
109+ case "image/webp":
110+ return "webp"
111+ case "image/gif":
112+ return "gif"
113+ default:
114+ return "bin"
115+ }
116+}
@@ -0,0 +1,116 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+package daemon
5+
6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// maxImagePushSize independently caps an accepted MsgImagePush payload,
15+// defending against an old or foreign client even though the client is
16+// expected to enforce the same cap before sending
17+// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
18+const maxImagePushSize = 10 << 20 // 10 MiB
19+
20+// Bracketed-paste markers used to wrap the injected clip path when the
21+// focused pane has DEC private mode 2004 enabled. Mirrors the client's
22+// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
23+// separate copy since the daemon has no dependency on the client package.
24+var (
25+ clipPasteOpenMarker = []byte("\x1b[200~")
26+ clipPasteCloseMarker = []byte("\x1b[201~")
27+)
28+
29+// handleImagePush writes an ImagePush's bytes to a temp file and injects the
30+// file's path into the session's focused pane, as if typed/pasted there.
31+// Oversized or empty payloads are rejected without panicking. A write
32+// failure is logged and the push is dropped: the client has already
33+// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
34+// to re-trigger from here.
35+func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
36+ if len(ip.Data) == 0 {
37+ return
38+ }
39+ if len(ip.Data) > maxImagePushSize {
40+ d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
41+ return
42+ }
43+ path, err := d.writeClipboardImage(sess, ip)
44+ if err != nil {
45+ d.log.Error("writing clipboard image failed", "err", err)
46+ return
47+ }
48+ d.injectClipboardPath(sess, path)
49+}
50+
51+// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
52+// the path on sess for best-effort cleanup at session end (killSession).
53+func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
54+ dir := d.tempDir
55+ if dir == "" {
56+ dir = os.TempDir()
57+ }
58+ name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime))
59+ path := filepath.Join(dir, name)
60+ if err := os.WriteFile(path, ip.Data, 0o600); err != nil {
61+ return "", err
62+ }
63+ sess.mu.Lock()
64+ sess.clipFiles = append(sess.clipFiles, path)
65+ sess.mu.Unlock()
66+ return path, nil
67+}
68+
69+// injectClipboardPath writes path's text into sess's focused pane, through
70+// the same PTY write path as ordinary input, wrapped in bracketed-paste
71+// markers iff the pane's Screen reports bracketed-paste mode enabled.
72+func (d *Daemon) injectClipboardPath(sess *session, path string) {
73+ tb := sess.activeTab()
74+ if tb == nil {
75+ return
76+ }
77+ tb.mu.Lock()
78+ p := tb.focusedPane()
79+ tb.mu.Unlock()
80+ if p == nil {
81+ return
82+ }
83+
84+ p.mu.Lock()
85+ bracketed := p.screen.BracketedPasteMode()
86+ p.mu.Unlock()
87+
88+ data := []byte(path)
89+ if bracketed {
90+ wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
91+ wrapped = append(wrapped, clipPasteOpenMarker...)
92+ wrapped = append(wrapped, data...)
93+ wrapped = append(wrapped, clipPasteCloseMarker...)
94+ data = wrapped
95+ }
96+ d.writeToPane(sess, p, data)
97+}
98+
99+// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
100+// types fall back to "bin" rather than rejecting the push: the daemon copies
101+// bytes as-is regardless (see the design doc's "no image conversion" scope
102+// decision).
103+func clipboardExt(mime string) string {
104+ switch mime {
105+ case "image/png":
106+ return "png"
107+ case "image/jpeg":
108+ return "jpg"
109+ case "image/webp":
110+ return "webp"
111+ case "image/gif":
112+ return "gif"
113+ default:
114+ return "bin"
115+ }
116+}
A internal/usecase/daemon/clipboard_test.go +151 -0
@@ -0,0 +1,151 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
14+ writes := make(chan []byte, 1)
15+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
16+ defer releasePTY()
17+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
18+ d.tempDir = t.TempDir()
19+
20+ data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
21+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
22+
23+ injected := <-writes
24+ path := string(injected)
25+ require.FileExists(t, path)
26+ require.Equal(t, ".png", filepath.Ext(path))
27+ require.True(t, filepath.IsAbs(path))
28+
29+ got, err := os.ReadFile(path)
30+ require.NoError(t, err)
31+ require.Equal(t, data, got)
32+
33+ info, err := os.Stat(path)
34+ require.NoError(t, err)
35+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
36+}
37+
38+func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
39+ writes := make(chan []byte, 1)
40+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
41+ defer releasePTY()
42+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
43+ d.tempDir = t.TempDir()
44+ pane := sess.tabs[0].focusedPane()
45+ require.False(t, pane.screen.BracketedPasteMode())
46+
47+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
48+
49+ injected := <-writes
50+ require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
51+ require.FileExists(t, string(injected))
52+}
53+
54+func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
55+ writes := make(chan []byte, 1)
56+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
57+ defer releasePTY()
58+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
59+ d.tempDir = t.TempDir()
60+ pane := sess.tabs[0].focusedPane()
61+ pane.screen.Write([]byte("\x1b[?2004h"))
62+ require.True(t, pane.screen.BracketedPasteMode())
63+
64+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
65+
66+ injected := <-writes
67+ require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
68+ require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
69+ require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
70+ path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
71+ require.FileExists(t, path)
72+}
73+
74+func hasBracketedPasteMarkers(b []byte) bool {
75+ return len(b) >= len(clipPasteOpenMarker) &&
76+ string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
77+}
78+
79+func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
80+ writes := make(chan []byte, 1)
81+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
82+ defer releasePTY()
83+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
84+ dir := t.TempDir()
85+ d.tempDir = dir
86+
87+ huge := make([]byte, maxImagePushSize+1)
88+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
89+
90+ select {
91+ case w := <-writes:
92+ t.Fatalf("oversized push must not be injected into the pane, got %q", w)
93+ default:
94+ }
95+ entries, err := os.ReadDir(dir)
96+ require.NoError(t, err)
97+ require.Empty(t, entries, "oversized push must not write a temp file")
98+}
99+
100+func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
101+ writes := make(chan []byte, 1)
102+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
103+ defer releasePTY()
104+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
105+ dir := t.TempDir()
106+ d.tempDir = dir
107+
108+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
109+
110+ select {
111+ case w := <-writes:
112+ t.Fatalf("empty push must not be injected, got %q", w)
113+ default:
114+ }
115+ entries, err := os.ReadDir(dir)
116+ require.NoError(t, err)
117+ require.Empty(t, entries)
118+}
119+
120+func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
121+ cases := map[string]string{
122+ "image/png": "png",
123+ "image/jpeg": "jpg",
124+ "image/webp": "webp",
125+ "image/gif": "gif",
126+ "image/bmp": "bin",
127+ "": "bin",
128+ "application/octet-stream": "bin",
129+ }
130+ for mime, want := range cases {
131+ require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
132+ }
133+}
134+
135+func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
136+ writes := make(chan []byte, 1)
137+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
138+ defer releasePTY()
139+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
140+ d.tempDir = t.TempDir()
141+ // killSession requires the session to be registered in the daemon's
142+ // registry (newManualSessionWithPTYs already does this via d.sessions).
143+
144+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
145+ injected := <-writes
146+ path := string(injected)
147+ require.FileExists(t, path)
148+
149+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
150+ require.NoFileExists(t, path)
151+}
@@ -0,0 +1,151 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
14+ writes := make(chan []byte, 1)
15+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
16+ defer releasePTY()
17+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
18+ d.tempDir = t.TempDir()
19+
20+ data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
21+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
22+
23+ injected := <-writes
24+ path := string(injected)
25+ require.FileExists(t, path)
26+ require.Equal(t, ".png", filepath.Ext(path))
27+ require.True(t, filepath.IsAbs(path))
28+
29+ got, err := os.ReadFile(path)
30+ require.NoError(t, err)
31+ require.Equal(t, data, got)
32+
33+ info, err := os.Stat(path)
34+ require.NoError(t, err)
35+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
36+}
37+
38+func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
39+ writes := make(chan []byte, 1)
40+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
41+ defer releasePTY()
42+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
43+ d.tempDir = t.TempDir()
44+ pane := sess.tabs[0].focusedPane()
45+ require.False(t, pane.screen.BracketedPasteMode())
46+
47+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
48+
49+ injected := <-writes
50+ require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
51+ require.FileExists(t, string(injected))
52+}
53+
54+func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
55+ writes := make(chan []byte, 1)
56+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
57+ defer releasePTY()
58+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
59+ d.tempDir = t.TempDir()
60+ pane := sess.tabs[0].focusedPane()
61+ pane.screen.Write([]byte("\x1b[?2004h"))
62+ require.True(t, pane.screen.BracketedPasteMode())
63+
64+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
65+
66+ injected := <-writes
67+ require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
68+ require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
69+ require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
70+ path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
71+ require.FileExists(t, path)
72+}
73+
74+func hasBracketedPasteMarkers(b []byte) bool {
75+ return len(b) >= len(clipPasteOpenMarker) &&
76+ string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
77+}
78+
79+func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
80+ writes := make(chan []byte, 1)
81+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
82+ defer releasePTY()
83+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
84+ dir := t.TempDir()
85+ d.tempDir = dir
86+
87+ huge := make([]byte, maxImagePushSize+1)
88+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
89+
90+ select {
91+ case w := <-writes:
92+ t.Fatalf("oversized push must not be injected into the pane, got %q", w)
93+ default:
94+ }
95+ entries, err := os.ReadDir(dir)
96+ require.NoError(t, err)
97+ require.Empty(t, entries, "oversized push must not write a temp file")
98+}
99+
100+func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
101+ writes := make(chan []byte, 1)
102+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
103+ defer releasePTY()
104+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
105+ dir := t.TempDir()
106+ d.tempDir = dir
107+
108+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
109+
110+ select {
111+ case w := <-writes:
112+ t.Fatalf("empty push must not be injected, got %q", w)
113+ default:
114+ }
115+ entries, err := os.ReadDir(dir)
116+ require.NoError(t, err)
117+ require.Empty(t, entries)
118+}
119+
120+func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
121+ cases := map[string]string{
122+ "image/png": "png",
123+ "image/jpeg": "jpg",
124+ "image/webp": "webp",
125+ "image/gif": "gif",
126+ "image/bmp": "bin",
127+ "": "bin",
128+ "application/octet-stream": "bin",
129+ }
130+ for mime, want := range cases {
131+ require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
132+ }
133+}
134+
135+func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
136+ writes := make(chan []byte, 1)
137+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
138+ defer releasePTY()
139+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
140+ d.tempDir = t.TempDir()
141+ // killSession requires the session to be registered in the daemon's
142+ // registry (newManualSessionWithPTYs already does this via d.sessions).
143+
144+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
145+ injected := <-writes
146+ path := string(injected)
147+ require.FileExists(t, path)
148+
149+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
150+ require.NoFileExists(t, path)
151+}
M internal/usecase/daemon/daemon.go +12 -0
@@ -103,6 +103,9 @@ type Daemon struct {
103103 persistEnabled bool
104104 procCwd func(int) (string, error)
105105 procComm func(int) (string, error)
106+ // tempDir overrides os.TempDir() for clipboard-image-transfer writes
107+ // (see clipboard.go); empty means use os.TempDir().
108+ tempDir string
106109
107110 serveCtx context.Context
108111 serveCancel context.CancelFunc
@@ -167,6 +170,15 @@ func WithCwdReader(fn func(int) (string, error)) Option {
167170 }
168171 }
169172
173+// WithTempDir overrides the directory clipboard-image-transfer writes temp
174+// files into (production default: os.TempDir()); tests use this with
175+// t.TempDir() so writes are isolated and auto-cleaned.
176+func WithTempDir(dir string) Option {
177+ return func(d *Daemon) {
178+ d.tempDir = dir
179+ }
180+}
181+
170182 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
171183 // render debounce, and log receives diagnostics (defaults to slog.Default).
172184 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -103,6 +103,9 @@ type Daemon struct {
103 persistEnabled bool103 persistEnabled bool
104 procCwd func(int) (string, error)104 procCwd func(int) (string, error)
105 procComm func(int) (string, error)105 procComm func(int) (string, error)
106+ // tempDir overrides os.TempDir() for clipboard-image-transfer writes
107+ // (see clipboard.go); empty means use os.TempDir().
108+ tempDir string
106 109
107 serveCtx context.Context110 serveCtx context.Context
108 serveCancel context.CancelFunc111 serveCancel context.CancelFunc
@@ -167,6 +170,15 @@ func WithCwdReader(fn func(int) (string, error)) Option {
167 }170 }
168 }171 }
169 172
173+// WithTempDir overrides the directory clipboard-image-transfer writes temp
174+// files into (production default: os.TempDir()); tests use this with
175+// t.TempDir() so writes are isolated and auto-cleaned.
176+func WithTempDir(dir string) Option {
177+ return func(d *Daemon) {
178+ d.tempDir = dir
179+ }
180+}
181+
170 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the182 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
171 // render debounce, and log receives diagnostics (defaults to slog.Default).183 // render debounce, and log receives diagnostics (defaults to slog.Default).
172 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {184 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
M internal/usecase/daemon/session.go +13 -1
@@ -28,6 +28,7 @@ import (
2828 "context"
2929 "errors"
3030 "fmt"
31+ "os"
3132 "strconv"
3233 "strings"
3334 "sync"
@@ -47,12 +48,16 @@ type session struct {
4748 ctx context.Context
4849 cancel context.CancelFunc
4950
50- mu sync.Mutex // guards tabs, active, and client
51+ mu sync.Mutex // guards tabs, active, client, and clipFiles
5152 tabs []*tab
5253 active int
5354 client *attachedClient
5455 cwd string
5556 createdAt int64
57+ // clipFiles records clipboard-image-transfer temp file paths (see
58+ // clipboard.go) written for this session, removed best-effort in
59+ // killSession.
60+ clipFiles []string
5661 }
5762
5863 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -467,11 +472,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
467472 sess.cancel()
468473 sess.mu.Lock()
469474 tabs := append([]*tab(nil), sess.tabs...)
475+ clipFiles := sess.clipFiles
476+ sess.clipFiles = nil
470477 sess.mu.Unlock()
471478 for _, tb := range tabs {
472479 d.clearDestroyedTabPreview(tb)
473480 tb.closeAllPanes()
474481 }
482+ for _, path := range clipFiles {
483+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
484+ d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
485+ }
486+ }
475487 if empty {
476488 d.doneOnce.Do(func() { close(d.done) })
477489 } else if ringing {
@@ -28,6 +28,7 @@ import (
28 "context"28 "context"
29 "errors"29 "errors"
30 "fmt"30 "fmt"
31+ "os"
31 "strconv"32 "strconv"
32 "strings"33 "strings"
33 "sync"34 "sync"
@@ -47,12 +48,16 @@ type session struct {
47 ctx context.Context48 ctx context.Context
48 cancel context.CancelFunc49 cancel context.CancelFunc
49 50
50- mu sync.Mutex // guards tabs, active, and client51+ mu sync.Mutex // guards tabs, active, client, and clipFiles
51 tabs []*tab52 tabs []*tab
52 active int53 active int
53 client *attachedClient54 client *attachedClient
54 cwd string55 cwd string
55 createdAt int6456 createdAt int64
57+ // clipFiles records clipboard-image-transfer temp file paths (see
58+ // clipboard.go) written for this session, removed best-effort in
59+ // killSession.
60+ clipFiles []string
56 }61 }
57 62
58 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.63 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -467,11 +472,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
467 sess.cancel()472 sess.cancel()
468 sess.mu.Lock()473 sess.mu.Lock()
469 tabs := append([]*tab(nil), sess.tabs...)474 tabs := append([]*tab(nil), sess.tabs...)
475+ clipFiles := sess.clipFiles
476+ sess.clipFiles = nil
470 sess.mu.Unlock()477 sess.mu.Unlock()
471 for _, tb := range tabs {478 for _, tb := range tabs {
472 d.clearDestroyedTabPreview(tb)479 d.clearDestroyedTabPreview(tb)
473 tb.closeAllPanes()480 tb.closeAllPanes()
474 }481 }
482+ for _, path := range clipFiles {
483+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
484+ d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
485+ }
486+ }
475 if empty {487 if empty {
476 d.doneOnce.Do(func() { close(d.done) })488 d.doneOnce.Do(func() { close(d.done) })
477 } else if ringing {489 } else if ringing {

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