detached 604f123

Merge pull request #14 from bnema/feature/clipboard-image-transfer

feat(client,daemon): transfer clipboard image to remote pane as temp-file path
commit
604f123191bf2dab34c796e557a0caf543bdab0f
author
bnema · · 2026-07-04 17:12 UTC
committer
GitHub · · 2026-07-04 17:12 UTC
merge of
748bdff + 76a1920 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +1390 -36
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 +113 -0
@@ -0,0 +1,113 @@
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. Keep this short so a slow clipboard read cannot noticeably
33+// freeze typing for longer than the image-transfer MVP's small payload cap.
34+const defaultReadTimeout = time.Second
35+
36+// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
37+// holds no state and is safe for concurrent use; every call runs a fresh
38+// subprocess.
39+type WlPaste struct {
40+ // execCommand constructs the command to run; overridable in tests so
41+ // command construction can be verified without requiring wl-paste (or
42+ // even Wayland) to be installed.
43+ execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
44+ // readTimeout bounds the data-read command; zero means defaultReadTimeout
45+ // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
46+ // still gets a sane default). Tests needing a short deadline set it
47+ // explicitly.
48+ readTimeout time.Duration
49+}
50+
51+// New returns a WlPaste clipboard reader that shells out to the real
52+// wl-paste binary.
53+func New() *WlPaste {
54+ return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
55+}
56+
57+func (w *WlPaste) readTimeoutOrDefault() time.Duration {
58+ if w.readTimeout <= 0 {
59+ return defaultReadTimeout
60+ }
61+ return w.readTimeout
62+}
63+
64+// ReadImage implements ports.ClipboardReader. It lists the clipboard's
65+// offered MIME types, picks the best supported image type (if any), and
66+// reads it. Any failure (wl-paste missing, no image on the clipboard, a
67+// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
68+// uniformly fall back to forwarding the keystroke that triggered the read.
69+func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
70+ listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
71+ defer cancel()
72+
73+ listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
74+ out, err := listCmd.Output()
75+ if err != nil {
76+ return "", nil, ports.ErrNoClipboardImage
77+ }
78+
79+ mime := pickImageMime(string(out))
80+ if mime == "" {
81+ return "", nil, ports.ErrNoClipboardImage
82+ }
83+
84+ readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
85+ defer readCancel()
86+
87+ readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
88+ var buf bytes.Buffer
89+ readCmd.Stdout = &buf
90+ if err := readCmd.Run(); err != nil {
91+ return "", nil, ports.ErrNoClipboardImage
92+ }
93+ return mime, buf.Bytes(), nil
94+}
95+
96+// pickImageMime scans wl-paste --list-types output (one MIME type per line)
97+// and returns the first match in mimePreference order, or "" if the
98+// clipboard offers no supported image type.
99+func pickImageMime(listTypesOutput string) string {
100+ offered := make(map[string]bool)
101+ for _, line := range strings.Split(listTypesOutput, "\n") {
102+ line = strings.TrimSpace(line)
103+ if line != "" {
104+ offered[line] = true
105+ }
106+ }
107+ for _, mime := range mimePreference {
108+ if offered[mime] {
109+ return mime
110+ }
111+ }
112+ return ""
113+}
@@ -0,0 +1,113 @@
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. Keep this short so a slow clipboard read cannot noticeably
33+// freeze typing for longer than the image-transfer MVP's small payload cap.
34+const defaultReadTimeout = time.Second
35+
36+// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
37+// holds no state and is safe for concurrent use; every call runs a fresh
38+// subprocess.
39+type WlPaste struct {
40+ // execCommand constructs the command to run; overridable in tests so
41+ // command construction can be verified without requiring wl-paste (or
42+ // even Wayland) to be installed.
43+ execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
44+ // readTimeout bounds the data-read command; zero means defaultReadTimeout
45+ // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
46+ // still gets a sane default). Tests needing a short deadline set it
47+ // explicitly.
48+ readTimeout time.Duration
49+}
50+
51+// New returns a WlPaste clipboard reader that shells out to the real
52+// wl-paste binary.
53+func New() *WlPaste {
54+ return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
55+}
56+
57+func (w *WlPaste) readTimeoutOrDefault() time.Duration {
58+ if w.readTimeout <= 0 {
59+ return defaultReadTimeout
60+ }
61+ return w.readTimeout
62+}
63+
64+// ReadImage implements ports.ClipboardReader. It lists the clipboard's
65+// offered MIME types, picks the best supported image type (if any), and
66+// reads it. Any failure (wl-paste missing, no image on the clipboard, a
67+// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
68+// uniformly fall back to forwarding the keystroke that triggered the read.
69+func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
70+ listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
71+ defer cancel()
72+
73+ listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
74+ out, err := listCmd.Output()
75+ if err != nil {
76+ return "", nil, ports.ErrNoClipboardImage
77+ }
78+
79+ mime := pickImageMime(string(out))
80+ if mime == "" {
81+ return "", nil, ports.ErrNoClipboardImage
82+ }
83+
84+ readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
85+ defer readCancel()
86+
87+ readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
88+ var buf bytes.Buffer
89+ readCmd.Stdout = &buf
90+ if err := readCmd.Run(); err != nil {
91+ return "", nil, ports.ErrNoClipboardImage
92+ }
93+ return mime, buf.Bytes(), nil
94+}
95+
96+// pickImageMime scans wl-paste --list-types output (one MIME type per line)
97+// and returns the first match in mimePreference order, or "" if the
98+// clipboard offers no supported image type.
99+func pickImageMime(listTypesOutput string) string {
100+ offered := make(map[string]bool)
101+ for _, line := range strings.Split(listTypesOutput, "\n") {
102+ line = strings.TrimSpace(line)
103+ if line != "" {
104+ offered[line] = true
105+ }
106+ }
107+ for _, mime := range mimePreference {
108+ if offered[mime] {
109+ return mime
110+ }
111+ }
112+ return ""
113+}
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/adapters/dgram/transport.go +15 -6
@@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) {
288288 return
289289 }
290290 if reliable {
291- if t.enqueueReliable(seq, f) {
291+ ack, queued := t.enqueueReliable(seq, f)
292+ if ack {
292293 t.sendAck(seq)
293294 }
295+ if queued {
296+ t.signalDelivery()
297+ }
294298 return
295299 }
296300 t.deliver(f)
297301 }
298302 }
299303
300-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
304+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ack bool, queued bool) {
301305 t.deliverMu.Lock()
302306 defer t.deliverMu.Unlock()
303307 if seq < t.nextRecvSeq {
304- return true
308+ return true, false
305309 }
306310 if _, exists := t.recvBuf[seq]; exists {
307- return true
311+ return true, false
308312 }
309313 if len(t.recvBuf) >= maxRecvBuffer {
310314 if seq != t.nextRecvSeq {
311- return false
315+ return false, false
312316 }
313317 for bufferedSeq := range t.recvBuf {
314318 delete(t.recvBuf, bufferedSeq)
@@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
316320 }
317321 }
318322 t.recvBuf[seq] = f
323+ return true, true
324+}
325+
326+func (t *Transport) signalDelivery() {
327+ t.deliverMu.Lock()
319328 t.deliverCond.Signal()
320- return true
329+ t.deliverMu.Unlock()
321330 }
322331
323332 func (t *Transport) deliveryLoop() {
@@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) {
288 return288 return
289 }289 }
290 if reliable {290 if reliable {
291- if t.enqueueReliable(seq, f) {291+ ack, queued := t.enqueueReliable(seq, f)
292+ if ack {
292 t.sendAck(seq)293 t.sendAck(seq)
293 }294 }
295+ if queued {
296+ t.signalDelivery()
297+ }
294 return298 return
295 }299 }
296 t.deliver(f)300 t.deliver(f)
297 }301 }
298 }302 }
299 303
300-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {304+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ack bool, queued bool) {
301 t.deliverMu.Lock()305 t.deliverMu.Lock()
302 defer t.deliverMu.Unlock()306 defer t.deliverMu.Unlock()
303 if seq < t.nextRecvSeq {307 if seq < t.nextRecvSeq {
304- return true308+ return true, false
305 }309 }
306 if _, exists := t.recvBuf[seq]; exists {310 if _, exists := t.recvBuf[seq]; exists {
307- return true311+ return true, false
308 }312 }
309 if len(t.recvBuf) >= maxRecvBuffer {313 if len(t.recvBuf) >= maxRecvBuffer {
310 if seq != t.nextRecvSeq {314 if seq != t.nextRecvSeq {
311- return false315+ return false, false
312 }316 }
313 for bufferedSeq := range t.recvBuf {317 for bufferedSeq := range t.recvBuf {
314 delete(t.recvBuf, bufferedSeq)318 delete(t.recvBuf, bufferedSeq)
@@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
316 }320 }
317 }321 }
318 t.recvBuf[seq] = f322 t.recvBuf[seq] = f
323+ return true, true
324+}
325+
326+func (t *Transport) signalDelivery() {
327+ t.deliverMu.Lock()
319 t.deliverCond.Signal()328 t.deliverCond.Signal()
320- return true329+ t.deliverMu.Unlock()
321 }330 }
322 331
323 func (t *Transport) deliveryLoop() {332 func (t *Transport) deliveryLoop() {
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"
@@ -286,6 +287,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
286287 remoteDialer: defaultRemoteDialer,
287288 runClient: client.Run,
288289 createDetached: createDetachedLocalSession,
290+ clipboard: clipboard.New(),
289291 })
290292 }
291293
@@ -299,8 +301,13 @@ type runAttachDeps struct {
299301 attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
300302 localDialer func() ports.Dialer
301303 remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
302- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, *slog.Logger) error
304+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
303305 createDetached func(context.Context, string) error
306+ // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
307+ // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
308+ // Only used for the remote-dialer branch below; local attaches never
309+ // intercept Ctrl+V regardless of this field.
310+ clipboard ports.ClipboardReader
304311 }
305312
306313 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
@@ -323,7 +330,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
323330 if log != nil {
324331 log.Info("attaching to remote session", "target", remoteTarget, "name", name)
325332 }
326- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, log)
333+ return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)
327334 }
328335
329336 attachLocal := deps.attachLocal
@@ -336,7 +343,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
336343 if log != nil {
337344 log.Info("attaching to local session", "intent", intent, "name", name)
338345 }
339- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, log)
346+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
340347 }
341348 }
342349 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"
@@ -286,6 +287,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
286 remoteDialer: defaultRemoteDialer,287 remoteDialer: defaultRemoteDialer,
287 runClient: client.Run,288 runClient: client.Run,
288 createDetached: createDetachedLocalSession,289 createDetached: createDetachedLocalSession,
290+ clipboard: clipboard.New(),
289 })291 })
290 }292 }
291 293
@@ -299,8 +301,13 @@ type runAttachDeps struct {
299 attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests301 attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
300 localDialer func() ports.Dialer302 localDialer func() ports.Dialer
301 remoteDialer func(target, session string, log *slog.Logger) ports.Dialer303 remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
302- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, *slog.Logger) error304+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
303 createDetached func(context.Context, string) error305 createDetached func(context.Context, string) error
306+ // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
307+ // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
308+ // Only used for the remote-dialer branch below; local attaches never
309+ // intercept Ctrl+V regardless of this field.
310+ clipboard ports.ClipboardReader
304 }311 }
305 312
306 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {313 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
@@ -323,7 +330,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
323 if log != nil {330 if log != nil {
324 log.Info("attaching to remote session", "target", remoteTarget, "name", name)331 log.Info("attaching to remote session", "target", remoteTarget, "name", name)
325 }332 }
326- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, log)333+ return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)
327 }334 }
328 335
329 attachLocal := deps.attachLocal336 attachLocal := deps.attachLocal
@@ -336,7 +343,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
336 if log != nil {343 if log != nil {
337 log.Info("attaching to local session", "intent", intent, "name", name)344 log.Info("attaching to local session", "intent", intent, "name", name)
338 }345 }
339- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, log)346+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
340 }347 }
341 }348 }
342 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{349 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
M internal/app/run_test.go +30 -2
@@ -394,15 +394,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
394394 return nil, errors.New("not used")
395395 }
396396
397+// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
398+// verify identity (that runAttachWithDeps threads the *same* reader through),
399+// never actually invoked in these wiring tests.
400+type fakeClipboardReader struct{ ports.ClipboardReader }
401+
397402 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
398403 var gotTarget, gotSession, gotDialer string
404+ var gotRemote bool
405+ var gotClipboard ports.ClipboardReader
406+ clip := &fakeClipboardReader{}
399407 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
400408 remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {
401409 gotTarget, gotSession = target, session
402410 return namedDialer{name: "remote"}
403411 },
404- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {
412+ clipboard: clip,
413+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
405414 gotDialer = d.(namedDialer).name
415+ gotRemote = remote
416+ gotClipboard = clipboard
406417 if intent != ports.IntentAttach || name != "work" {
407418 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
408419 }
@@ -415,14 +426,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
415426 if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
416427 t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
417428 }
429+ if !gotRemote {
430+ t.Fatal("remote attach must pass remote=true to runClient")
431+ }
432+ if gotClipboard != ports.ClipboardReader(clip) {
433+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
434+ }
418435 }
419436
420437 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
421438 var gotDialer string
439+ gotRemote := true
440+ gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
422441 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
423442 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
424- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {
443+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
444+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
425445 gotDialer = d.(namedDialer).name
446+ gotRemote = remote
447+ gotClipboard = clipboard
426448 if intent != ports.IntentEphemeral || name != "" {
427449 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
428450 }
@@ -435,4 +457,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
435457 if gotDialer != "local" {
436458 t.Fatalf("local dialer = %q, want local", gotDialer)
437459 }
460+ if gotRemote {
461+ t.Fatal("local attach must pass remote=false to runClient")
462+ }
463+ if gotClipboard != nil {
464+ t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
465+ }
438466 }
@@ -394,15 +394,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
394 return nil, errors.New("not used")394 return nil, errors.New("not used")
395 }395 }
396 396
397+// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
398+// verify identity (that runAttachWithDeps threads the *same* reader through),
399+// never actually invoked in these wiring tests.
400+type fakeClipboardReader struct{ ports.ClipboardReader }
401+
397 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {402 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
398 var gotTarget, gotSession, gotDialer string403 var gotTarget, gotSession, gotDialer string
404+ var gotRemote bool
405+ var gotClipboard ports.ClipboardReader
406+ clip := &fakeClipboardReader{}
399 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{407 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
400 remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {408 remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {
401 gotTarget, gotSession = target, session409 gotTarget, gotSession = target, session
402 return namedDialer{name: "remote"}410 return namedDialer{name: "remote"}
403 },411 },
404- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {412+ clipboard: clip,
413+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
405 gotDialer = d.(namedDialer).name414 gotDialer = d.(namedDialer).name
415+ gotRemote = remote
416+ gotClipboard = clipboard
406 if intent != ports.IntentAttach || name != "work" {417 if intent != ports.IntentAttach || name != "work" {
407 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)418 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
408 }419 }
@@ -415,14 +426,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
415 if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {426 if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
416 t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)427 t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
417 }428 }
429+ if !gotRemote {
430+ t.Fatal("remote attach must pass remote=true to runClient")
431+ }
432+ if gotClipboard != ports.ClipboardReader(clip) {
433+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
434+ }
418 }435 }
419 436
420 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {437 func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
421 var gotDialer string438 var gotDialer string
439+ gotRemote := true
440+ gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
422 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{441 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
423 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },442 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
424- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {443+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
444+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
425 gotDialer = d.(namedDialer).name445 gotDialer = d.(namedDialer).name
446+ gotRemote = remote
447+ gotClipboard = clipboard
426 if intent != ports.IntentEphemeral || name != "" {448 if intent != ports.IntentEphemeral || name != "" {
427 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)449 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
428 }450 }
@@ -435,4 +457,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
435 if gotDialer != "local" {457 if gotDialer != "local" {
436 t.Fatalf("local dialer = %q, want local", gotDialer)458 t.Fatalf("local dialer = %q, want local", gotDialer)
437 }459 }
460+ if gotRemote {
461+ t.Fatal("local attach must pass remote=false to runClient")
462+ }
463+ if gotClipboard != nil {
464+ t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
465+ }
438 }466 }
M internal/ports/frame.go +11 -10
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 5
4+const ProtocolVersion uint16 = 7
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -10,15 +10,16 @@ type MsgType uint8
1010 // through 15, server-originated messages from 16, leaving room for growth in
1111 // each band.
1212 const (
13- MsgHello MsgType = 1
14- MsgInput MsgType = 2
15- MsgResize MsgType = 3
16- MsgDetach MsgType = 4
17- MsgPing MsgType = 5
18- MsgList MsgType = 6
19- MsgKill MsgType = 7
20- MsgTheme MsgType = 8
21- MsgAck MsgType = 9
13+ MsgHello MsgType = 1
14+ MsgInput MsgType = 2
15+ MsgResize MsgType = 3
16+ MsgDetach MsgType = 4
17+ MsgPing MsgType = 5
18+ MsgList MsgType = 6
19+ MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
2223
2324 MsgWelcome MsgType = 16
2425 MsgError MsgType = 17
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 54+const ProtocolVersion uint16 = 7
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
@@ -10,15 +10,16 @@ type MsgType uint8
10 // through 15, server-originated messages from 16, leaving room for growth in10 // through 15, server-originated messages from 16, leaving room for growth in
11 // each band.11 // each band.
12 const (12 const (
13- MsgHello MsgType = 113+ MsgHello MsgType = 1
14- MsgInput MsgType = 214+ MsgInput MsgType = 2
15- MsgResize MsgType = 315+ MsgResize MsgType = 3
16- MsgDetach MsgType = 416+ MsgDetach MsgType = 4
17- MsgPing MsgType = 517+ MsgPing MsgType = 5
18- MsgList MsgType = 618+ MsgList MsgType = 6
19- MsgKill MsgType = 719+ MsgKill MsgType = 7
20- MsgTheme MsgType = 820+ MsgTheme MsgType = 8
21- MsgAck MsgType = 921+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
22 23
23 MsgWelcome MsgType = 1624 MsgWelcome MsgType = 16
24 MsgError MsgType = 1725 MsgError MsgType = 17
M internal/ports/frame_test.go +3 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 5 {
7- t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion)
6+ if ProtocolVersion != 7 {
7+ t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion)
88 }
99 }
1010
@@ -22,6 +22,7 @@ func TestMsgTypeUnique(t *testing.T) {
2222 {"MsgKill", MsgKill},
2323 {"MsgTheme", MsgTheme},
2424 {"MsgAck", MsgAck},
25+ {"MsgImagePush", MsgImagePush},
2526 {"MsgWelcome", MsgWelcome},
2627 {"MsgError", MsgError},
2728 {"MsgOutput", MsgOutput},
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 5 {6+ if ProtocolVersion != 7 {
7- t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -22,6 +22,7 @@ func TestMsgTypeUnique(t *testing.T) {
22 {"MsgKill", MsgKill},22 {"MsgKill", MsgKill},
23 {"MsgTheme", MsgTheme},23 {"MsgTheme", MsgTheme},
24 {"MsgAck", MsgAck},24 {"MsgAck", MsgAck},
25+ {"MsgImagePush", MsgImagePush},
25 {"MsgWelcome", MsgWelcome},26 {"MsgWelcome", MsgWelcome},
26 {"MsgError", MsgError},27 {"MsgError", MsgError},
27 {"MsgOutput", MsgOutput},28 {"MsgOutput", MsgOutput},
M internal/ports/mocks/mocks.go +95 -0
@@ -1833,3 +1833,98 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
18331833 _c.Call.Return(run)
18341834 return _c
18351835 }
1836+
1837+// NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1838+// The first argument is typically a *testing.T value.
1839+func NewMockClipboardReader(t interface {
1840+ mock.TestingT
1841+ Cleanup(func())
1842+}) *MockClipboardReader {
1843+ mock := &MockClipboardReader{}
1844+ mock.Mock.Test(t)
1845+
1846+ t.Cleanup(func() { mock.AssertExpectations(t) })
1847+
1848+ return mock
1849+}
1850+
1851+// MockClipboardReader is an autogenerated mock type for the ClipboardReader type
1852+type MockClipboardReader struct {
1853+ mock.Mock
1854+}
1855+
1856+type MockClipboardReader_Expecter struct {
1857+ mock *mock.Mock
1858+}
1859+
1860+func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter {
1861+ return &MockClipboardReader_Expecter{mock: &_m.Mock}
1862+}
1863+
1864+// ReadImage provides a mock function for the type MockClipboardReader
1865+func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) {
1866+ ret := _mock.Called(ctx)
1867+
1868+ if len(ret) == 0 {
1869+ panic("no return value specified for ReadImage")
1870+ }
1871+
1872+ var r0 string
1873+ var r1 []byte
1874+ var r2 error
1875+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
1876+ return returnFunc(ctx)
1877+ }
1878+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
1879+ r0 = returnFunc(ctx)
1880+ } else {
1881+ r0 = ret.Get(0).(string)
1882+ }
1883+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
1884+ r1 = returnFunc(ctx)
1885+ } else {
1886+ if ret.Get(1) != nil {
1887+ r1 = ret.Get(1).([]byte)
1888+ }
1889+ }
1890+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
1891+ r2 = returnFunc(ctx)
1892+ } else {
1893+ r2 = ret.Error(2)
1894+ }
1895+ return r0, r1, r2
1896+}
1897+
1898+// MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage'
1899+type MockClipboardReader_ReadImage_Call struct {
1900+ *mock.Call
1901+}
1902+
1903+// ReadImage is a helper method to define mock.On call
1904+// - ctx context.Context
1905+func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call {
1906+ return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)}
1907+}
1908+
1909+func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call {
1910+ _c.Call.Run(func(args mock.Arguments) {
1911+ var arg0 context.Context
1912+ if args[0] != nil {
1913+ arg0 = args[0].(context.Context)
1914+ }
1915+ run(
1916+ arg0,
1917+ )
1918+ })
1919+ return _c
1920+}
1921+
1922+func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call {
1923+ _c.Call.Return(mime, data, err)
1924+ return _c
1925+}
1926+
1927+func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call {
1928+ _c.Call.Return(run)
1929+ return _c
1930+}
@@ -1833,3 +1833,98 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
1833 _c.Call.Return(run)1833 _c.Call.Return(run)
1834 return _c1834 return _c
1835 }1835 }
1836+
1837+// NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1838+// The first argument is typically a *testing.T value.
1839+func NewMockClipboardReader(t interface {
1840+ mock.TestingT
1841+ Cleanup(func())
1842+}) *MockClipboardReader {
1843+ mock := &MockClipboardReader{}
1844+ mock.Mock.Test(t)
1845+
1846+ t.Cleanup(func() { mock.AssertExpectations(t) })
1847+
1848+ return mock
1849+}
1850+
1851+// MockClipboardReader is an autogenerated mock type for the ClipboardReader type
1852+type MockClipboardReader struct {
1853+ mock.Mock
1854+}
1855+
1856+type MockClipboardReader_Expecter struct {
1857+ mock *mock.Mock
1858+}
1859+
1860+func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter {
1861+ return &MockClipboardReader_Expecter{mock: &_m.Mock}
1862+}
1863+
1864+// ReadImage provides a mock function for the type MockClipboardReader
1865+func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) {
1866+ ret := _mock.Called(ctx)
1867+
1868+ if len(ret) == 0 {
1869+ panic("no return value specified for ReadImage")
1870+ }
1871+
1872+ var r0 string
1873+ var r1 []byte
1874+ var r2 error
1875+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
1876+ return returnFunc(ctx)
1877+ }
1878+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
1879+ r0 = returnFunc(ctx)
1880+ } else {
1881+ r0 = ret.Get(0).(string)
1882+ }
1883+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
1884+ r1 = returnFunc(ctx)
1885+ } else {
1886+ if ret.Get(1) != nil {
1887+ r1 = ret.Get(1).([]byte)
1888+ }
1889+ }
1890+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
1891+ r2 = returnFunc(ctx)
1892+ } else {
1893+ r2 = ret.Error(2)
1894+ }
1895+ return r0, r1, r2
1896+}
1897+
1898+// MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage'
1899+type MockClipboardReader_ReadImage_Call struct {
1900+ *mock.Call
1901+}
1902+
1903+// ReadImage is a helper method to define mock.On call
1904+// - ctx context.Context
1905+func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call {
1906+ return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)}
1907+}
1908+
1909+func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call {
1910+ _c.Call.Run(func(args mock.Arguments) {
1911+ var arg0 context.Context
1912+ if args[0] != nil {
1913+ arg0 = args[0].(context.Context)
1914+ }
1915+ run(
1916+ arg0,
1917+ )
1918+ })
1919+ return _c
1920+}
1921+
1922+func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call {
1923+ _c.Call.Return(mime, data, err)
1924+ return _c
1925+}
1926+
1927+func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call {
1928+ _c.Call.Return(run)
1929+ return _c
1930+}
M internal/ports/ports.go +18 -0
@@ -2,12 +2,18 @@ package ports
22
33 import (
44 "context"
5+ "errors"
56 "io"
67 "time"
78
89 "github.com/bnema/vev/internal/domain"
910 )
1011
12+// ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the
13+// clipboard holds no image (or an unsupported type). Callers fall back to
14+// forwarding whatever keystroke triggered the read.
15+var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
16+
1117 // PTY is a running pseudo-terminal-backed child process.
1218 type PTY interface {
1319 io.ReadWriteCloser // Read = child output, Write = child input
@@ -75,3 +81,15 @@ type Timer interface {
7581 Reset(d time.Duration) bool
7682 Stop() bool
7783 }
84+
85+// ClipboardReader reads an image off the CLIENT-side system clipboard, for
86+// forwarding to a remote session's focused pane. Implementations live in
87+// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
88+// see this interface. See
89+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
90+type ClipboardReader interface {
91+ // ReadImage returns the clipboard's image bytes and MIME type. It returns
92+ // ErrNoClipboardImage when the clipboard holds no image (or an
93+ // unsupported type).
94+ ReadImage(ctx context.Context) (mime string, data []byte, err error)
95+}
@@ -2,12 +2,18 @@ package ports
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "io"6 "io"
6 "time"7 "time"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 )10 )
10 11
12+// ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the
13+// clipboard holds no image (or an unsupported type). Callers fall back to
14+// forwarding whatever keystroke triggered the read.
15+var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
16+
11 // PTY is a running pseudo-terminal-backed child process.17 // PTY is a running pseudo-terminal-backed child process.
12 type PTY interface {18 type PTY interface {
13 io.ReadWriteCloser // Read = child output, Write = child input19 io.ReadWriteCloser // Read = child output, Write = child input
@@ -75,3 +81,15 @@ type Timer interface {
75 Reset(d time.Duration) bool81 Reset(d time.Duration) bool
76 Stop() bool82 Stop() bool
77 }83 }
84+
85+// ClipboardReader reads an image off the CLIENT-side system clipboard, for
86+// forwarding to a remote session's focused pane. Implementations live in
87+// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
88+// see this interface. See
89+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
90+type ClipboardReader interface {
91+ // ReadImage returns the clipboard's image bytes and MIME type. It returns
92+ // ErrNoClipboardImage when the clipboard holds no image (or an
93+ // unsupported type).
94+ ReadImage(ctx context.Context) (mime string, data []byte, err error)
95+}
M internal/ports/wire.go +34 -0
@@ -87,6 +87,15 @@ type Theme struct {
8787 TrueColor bool
8888 }
8989
90+// ImagePush carries a clipboard image from a remote client, to be written to
91+// a temp file and injected into the focused pane's PTY as a path (see
92+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
93+type ImagePush struct {
94+ InputSeq uint64
95+ Mime string
96+ Data []byte
97+}
98+
9099 // Detach asks the daemon to detach the current client without killing the
91100 // session.
92101 type Detach struct{}
@@ -357,6 +366,31 @@ func UnmarshalInput(b []byte) (Input, error) {
357366 return Input{InputSeq: seq, Data: r.rest()}, nil
358367 }
359368
369+// MarshalImagePush encodes m into an ImagePush message payload.
370+func MarshalImagePush(m ImagePush) []byte {
371+ w := payloadWriter{}
372+ w.putUint64(m.InputSeq)
373+ w.putString(m.Mime)
374+ w.putBytes(m.Data)
375+ return w.b
376+}
377+
378+// UnmarshalImagePush decodes an ImagePush message payload. After the fixed
379+// input sequence and length-prefixed mime string, the rest of the payload is
380+// data; there is no length prefix for it.
381+func UnmarshalImagePush(b []byte) (ImagePush, error) {
382+ r := payloadReader{b: b}
383+ seq, err := r.getUint64()
384+ if err != nil {
385+ return ImagePush{}, err
386+ }
387+ mime, err := r.getString()
388+ if err != nil {
389+ return ImagePush{}, err
390+ }
391+ return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
392+}
393+
360394 // MarshalResize encodes m into a Resize message payload.
361395 func MarshalResize(m Resize) []byte {
362396 w := payloadWriter{}
@@ -87,6 +87,15 @@ type Theme struct {
87 TrueColor bool87 TrueColor bool
88 }88 }
89 89
90+// ImagePush carries a clipboard image from a remote client, to be written to
91+// a temp file and injected into the focused pane's PTY as a path (see
92+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
93+type ImagePush struct {
94+ InputSeq uint64
95+ Mime string
96+ Data []byte
97+}
98+
90 // Detach asks the daemon to detach the current client without killing the99 // Detach asks the daemon to detach the current client without killing the
91 // session.100 // session.
92 type Detach struct{}101 type Detach struct{}
@@ -357,6 +366,31 @@ func UnmarshalInput(b []byte) (Input, error) {
357 return Input{InputSeq: seq, Data: r.rest()}, nil366 return Input{InputSeq: seq, Data: r.rest()}, nil
358 }367 }
359 368
369+// MarshalImagePush encodes m into an ImagePush message payload.
370+func MarshalImagePush(m ImagePush) []byte {
371+ w := payloadWriter{}
372+ w.putUint64(m.InputSeq)
373+ w.putString(m.Mime)
374+ w.putBytes(m.Data)
375+ return w.b
376+}
377+
378+// UnmarshalImagePush decodes an ImagePush message payload. After the fixed
379+// input sequence and length-prefixed mime string, the rest of the payload is
380+// data; there is no length prefix for it.
381+func UnmarshalImagePush(b []byte) (ImagePush, error) {
382+ r := payloadReader{b: b}
383+ seq, err := r.getUint64()
384+ if err != nil {
385+ return ImagePush{}, err
386+ }
387+ mime, err := r.getString()
388+ if err != nil {
389+ return ImagePush{}, err
390+ }
391+ return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
392+}
393+
360 // MarshalResize encodes m into a Resize message payload.394 // MarshalResize encodes m into a Resize message payload.
361 func MarshalResize(m Resize) []byte {395 func MarshalResize(m Resize) []byte {
362 w := payloadWriter{}396 w := payloadWriter{}
M internal/ports/wire_test.go +34 -0
@@ -140,6 +140,40 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
140140 assertAllPrefixesFail(t, full[:8], UnmarshalInput)
141141 }
142142
143+func TestImagePushGoldenAndRoundTrip(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ msg ImagePush
147+ want []byte
148+ }{
149+ {
150+ name: "png data",
151+ msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
152+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
153+ },
154+ {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
155+ }
156+
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ got := MarshalImagePush(tt.msg)
160+ if !bytes.Equal(got, tt.want) {
161+ t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want)
162+ }
163+ back, err := UnmarshalImagePush(got)
164+ if err != nil {
165+ t.Fatalf("UnmarshalImagePush() error = %v", err)
166+ }
167+ if !reflect.DeepEqual(back, tt.msg) {
168+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
169+ }
170+ })
171+ }
172+
173+ full := MarshalImagePush(tests[0].msg)
174+ assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
175+}
176+
143177 func TestThemeGoldenAndRoundTrip(t *testing.T) {
144178 tests := []struct {
145179 name string
@@ -140,6 +140,40 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
140 assertAllPrefixesFail(t, full[:8], UnmarshalInput)140 assertAllPrefixesFail(t, full[:8], UnmarshalInput)
141 }141 }
142 142
143+func TestImagePushGoldenAndRoundTrip(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ msg ImagePush
147+ want []byte
148+ }{
149+ {
150+ name: "png data",
151+ msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
152+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
153+ },
154+ {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
155+ }
156+
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ got := MarshalImagePush(tt.msg)
160+ if !bytes.Equal(got, tt.want) {
161+ t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want)
162+ }
163+ back, err := UnmarshalImagePush(got)
164+ if err != nil {
165+ t.Fatalf("UnmarshalImagePush() error = %v", err)
166+ }
167+ if !reflect.DeepEqual(back, tt.msg) {
168+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
169+ }
170+ })
171+ }
172+
173+ full := MarshalImagePush(tests[0].msg)
174+ assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
175+}
176+
143 func TestThemeGoldenAndRoundTrip(t *testing.T) {177 func TestThemeGoldenAndRoundTrip(t *testing.T) {
144 tests := []struct {178 tests := []struct {
145 name string179 name string
M internal/usecase/client/client.go +38 -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, log *slog.Logger) (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, log *slog.Logger) (retErr error) {
123130 if log == nil {
124131 log = slog.Default()
125132 }
@@ -192,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
192199 }
193200 ms.dialed = true
194201
195- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log)
202+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
196203 if result.welcomed {
197204 backoff = defaultReconnectBackoff.initial
198205 }
@@ -255,7 +262,7 @@ func shouldReconnect(err error) bool {
255262 //
256263 // It is kept as a compatibility wrapper for callers that still own dialing.
257264 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
258- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, slog.Default())
265+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
259266 }
260267
261268 type singleTransportDialer struct{ transport ports.Transport }
@@ -270,7 +277,7 @@ type attachResult struct {
270277 err error
271278 }
272279
273-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(), log *slog.Logger) attachResult {
280+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, log *slog.Logger) attachResult {
274281 // 1. Handshake: send Hello with our size and TERM.
275282 size, err := term.Size()
276283 if err != nil {
@@ -336,8 +343,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
336343 sendCh := make(chan ports.Frame, sendQueueDepth)
337344 sendErrCh := make(chan error, 1)
338345
346+ var clip ports.ClipboardReader
347+ if remote {
348+ clip = clipboard
349+ }
339350 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
340- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, log)
351+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)
341352 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
342353
343354 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -461,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
461472 // exits. That is harmless here — Attach has already returned and restored
462473 // the terminal — and matches the standard pattern for stdin pumps; a
463474 // closable stdin duplicate could lift it later if ever needed.
464-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, log *slog.Logger) {
475+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) {
465476 defer log.Debug("stdin pump exited")
466477 buf := make([]byte, stdinBufSize)
467478 var scanner theme.Scanner
@@ -488,6 +499,26 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
488499 send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
489500 })
490501 defer coalescer.Close()
502+
503+ // On a remote attach with a ClipboardReader configured, splice in the
504+ // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
505+ // becomes a clipboard image push instead of reaching the coalescer (and
506+ // from there the remote pane) as an ordinary keystroke.
507+ sink := coalescer.Scan
508+ if clipboard != nil {
509+ ci := &clipboardIntercept{
510+ coalescer: coalescer,
511+ reader: clipboard,
512+ log: log,
513+ sendImage: func(mime string, data []byte) {
514+ inputSeq++
515+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
516+ },
517+ next: coalescer.Scan,
518+ }
519+ sink = ci.Scan
520+ }
521+
491522 for {
492523 n, rerr := in.Read(buf)
493524 if n > 0 {
@@ -503,7 +534,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
503534 return
504535 }
505536 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
506- }, coalescer.Scan)
537+ }, sink)
507538 if !sendOK.Load() {
508539 return
509540 }
@@ -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, log *slog.Logger) (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, log *slog.Logger) (retErr error) {
123 if log == nil {130 if log == nil {
124 log = slog.Default()131 log = slog.Default()
125 }132 }
@@ -192,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
192 }199 }
193 ms.dialed = true200 ms.dialed = true
194 201
195- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log)202+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
196 if result.welcomed {203 if result.welcomed {
197 backoff = defaultReconnectBackoff.initial204 backoff = defaultReconnectBackoff.initial
198 }205 }
@@ -255,7 +262,7 @@ func shouldReconnect(err error) bool {
255 //262 //
256 // It is kept as a compatibility wrapper for callers that still own dialing.263 // It is kept as a compatibility wrapper for callers that still own dialing.
257 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {264 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
258- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, slog.Default())265+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
259 }266 }
260 267
261 type singleTransportDialer struct{ transport ports.Transport }268 type singleTransportDialer struct{ transport ports.Transport }
@@ -270,7 +277,7 @@ type attachResult struct {
270 err error277 err error
271 }278 }
272 279
273-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(), log *slog.Logger) attachResult {280+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, log *slog.Logger) attachResult {
274 // 1. Handshake: send Hello with our size and TERM.281 // 1. Handshake: send Hello with our size and TERM.
275 size, err := term.Size()282 size, err := term.Size()
276 if err != nil {283 if err != nil {
@@ -336,8 +343,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
336 sendCh := make(chan ports.Frame, sendQueueDepth)343 sendCh := make(chan ports.Frame, sendQueueDepth)
337 sendErrCh := make(chan error, 1)344 sendErrCh := make(chan error, 1)
338 345
346+ var clip ports.ClipboardReader
347+ if remote {
348+ clip = clipboard
349+ }
339 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)350 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
340- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, log)351+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)
341 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)352 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
342 353
343 // 5. Output/main loop: the only goroutine that touches the terminal.354 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -461,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
461 // exits. That is harmless here — Attach has already returned and restored472 // exits. That is harmless here — Attach has already returned and restored
462 // the terminal — and matches the standard pattern for stdin pumps; a473 // the terminal — and matches the standard pattern for stdin pumps; a
463 // closable stdin duplicate could lift it later if ever needed.474 // closable stdin duplicate could lift it later if ever needed.
464-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, log *slog.Logger) {475+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) {
465 defer log.Debug("stdin pump exited")476 defer log.Debug("stdin pump exited")
466 buf := make([]byte, stdinBufSize)477 buf := make([]byte, stdinBufSize)
467 var scanner theme.Scanner478 var scanner theme.Scanner
@@ -488,6 +499,26 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
488 send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})499 send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
489 })500 })
490 defer coalescer.Close()501 defer coalescer.Close()
502+
503+ // On a remote attach with a ClipboardReader configured, splice in the
504+ // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
505+ // becomes a clipboard image push instead of reaching the coalescer (and
506+ // from there the remote pane) as an ordinary keystroke.
507+ sink := coalescer.Scan
508+ if clipboard != nil {
509+ ci := &clipboardIntercept{
510+ coalescer: coalescer,
511+ reader: clipboard,
512+ log: log,
513+ sendImage: func(mime string, data []byte) {
514+ inputSeq++
515+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
516+ },
517+ next: coalescer.Scan,
518+ }
519+ sink = ci.Scan
520+ }
521+
491 for {522 for {
492 n, rerr := in.Read(buf)523 n, rerr := in.Read(buf)
493 if n > 0 {524 if n > 0 {
@@ -503,7 +534,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
503 return534 return
504 }535 }
505 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})536 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
506- }, coalescer.Scan)537+ }, sink)
507 if !sendOK.Load() {538 if !sendOK.Load() {
508 return539 return
509 }540 }
M internal/usecase/client/client_test.go +5 -5
@@ -650,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
650650 d := portsmocks.NewMockDialer(t)
651651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
652652
653- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
653+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
654654 require.Error(t, err)
655655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
656656 }
@@ -670,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
670670 d := portsmocks.NewMockDialer(t)
671671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
672672
673- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
673+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
674674 require.Error(t, err)
675675 var pe *client.ProtocolError
676676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -682,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
682682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
683683 tm := portsmocks.NewMockTerminal(t)
684684
685- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
685+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
686686 require.ErrorIs(t, err, dialErr)
687687 }
688688
@@ -771,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
771771 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
772772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
773773
774- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
774+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
775775 require.NoError(t, err)
776776 require.Equal(t, int32(3), d.calls.Load())
777777 require.Equal(t, int32(1), term.rawCount.Load())
@@ -798,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
798798 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
799799 d := &sequenceDialer{trs: []ports.Transport{tr}}
800800
801- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
801+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
802802 require.Error(t, err)
803803 var de *client.DetachedError
804804 require.True(t, errors.As(err, &de))
@@ -650,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
650 d := portsmocks.NewMockDialer(t)650 d := portsmocks.NewMockDialer(t)
651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
652 652
653- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))653+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
654 require.Error(t, err)654 require.Error(t, err)
655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
656 }656 }
@@ -670,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
670 d := portsmocks.NewMockDialer(t)670 d := portsmocks.NewMockDialer(t)
671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
672 672
673- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))673+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
674 require.Error(t, err)674 require.Error(t, err)
675 var pe *client.ProtocolError675 var pe *client.ProtocolError
676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -682,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
683 tm := portsmocks.NewMockTerminal(t)683 tm := portsmocks.NewMockTerminal(t)
684 684
685- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))685+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
686 require.ErrorIs(t, err, dialErr)686 require.ErrorIs(t, err, dialErr)
687 }687 }
688 688
@@ -771,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
771 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}771 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
773 773
774- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))774+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
775 require.NoError(t, err)775 require.NoError(t, err)
776 require.Equal(t, int32(3), d.calls.Load())776 require.Equal(t, int32(3), d.calls.Load())
777 require.Equal(t, int32(1), term.rawCount.Load())777 require.Equal(t, int32(1), term.rawCount.Load())
@@ -798,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
798 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}798 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
799 d := &sequenceDialer{trs: []ports.Transport{tr}}799 d := &sequenceDialer{trs: []ports.Transport{tr}}
800 800
801- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))801+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
802 require.Error(t, err)802 require.Error(t, err)
803 var de *client.DetachedError803 var de *client.DetachedError
804 require.True(t, errors.As(err, &de))804 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. The 1 MiB cap fits
17+// both sshstdio and the datagram transport's fragmented payload ceiling.
18+const maxClipboardImagePush = 1 << 20 // 1 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, ports.BracketedPasteOpenMarker); 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. The 1 MiB cap fits
17+// both sshstdio and the datagram transport's fragmented payload ceiling.
18+const maxClipboardImagePush = 1 << 20 // 1 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, ports.BracketedPasteOpenMarker); 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 +177 -0
@@ -0,0 +1,177 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// clipboardRunTestDeps wires a Run() attempt with an input reader that
21+// delivers data then blocks (so the pump stays parked until detach), a
22+// transport that captures every MsgInput/MsgImagePush send, and a Recv that
23+// answers Welcome then, once triggered, Detached — mirroring the pattern used
24+// by the OSC-color-response test elsewhere in this package.
25+func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
26+ t.Helper()
27+ var out bytes.Buffer
28+ var restoreCount int32
29+ resizeCh := make(chan domain.Size)
30+ input := newOneShotBlockingReader(stdin)
31+ defer input.unblock()
32+
33+ tm := portsmocks.NewMockTerminal(t)
34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
35+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36+ tm.EXPECT().In().Return(input).Maybe()
37+ tm.EXPECT().Out().Return(&out).Maybe()
38+ tm.EXPECT().Flush().Return(nil).Maybe()
39+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
40+
41+ gotInput = make(chan []byte, 8)
42+ gotImage = make(chan ports.ImagePush, 1)
43+ allowDetach := make(chan struct{})
44+
45+ tr := portsmocks.NewMockTransport(t)
46+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
47+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
48+ in, err := ports.UnmarshalInput(f.Payload)
49+ require.NoError(t, err)
50+ gotInput <- append([]byte(nil), in.Data...)
51+ if bytes.IndexByte(in.Data, triggerByte) >= 0 {
52+ closeOnce(allowDetach)
53+ }
54+ return nil
55+ }).Maybe()
56+ tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
57+ ip, err := ports.UnmarshalImagePush(f.Payload)
58+ require.NoError(t, err)
59+ gotImage <- ip
60+ return nil
61+ }).Maybe()
62+
63+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
64+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
65+ recvCh := make(chan recvItem, 1)
66+ recvCh <- recvItem{f: welcome}
67+ closed := make(chan struct{})
68+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
69+ select {
70+ case it := <-recvCh:
71+ return it.f, it.err
72+ case <-allowDetach:
73+ select {
74+ case <-closed:
75+ return ports.Frame{}, io.EOF
76+ default:
77+ close(closed)
78+ return detached, nil
79+ }
80+ case <-closed:
81+ return ports.Frame{}, io.EOF
82+ }
83+ }).Maybe()
84+ tr.EXPECT().Close().Return(nil).Once()
85+
86+ d := portsmocks.NewMockDialer(t)
87+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
88+
89+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
90+ require.NoError(t, err)
91+ return gotInput, gotImage
92+}
93+
94+func closeOnce(ch chan struct{}) {
95+ select {
96+ case <-ch:
97+ default:
98+ close(ch)
99+ }
100+}
101+
102+func drainInput(gotInput chan []byte) []byte {
103+ var b []byte
104+ for {
105+ select {
106+ case chunk := <-gotInput:
107+ b = append(b, chunk...)
108+ default:
109+ return b
110+ }
111+ }
112+}
113+
114+func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
115+ clip := portsmocks.NewMockClipboardReader(t)
116+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
117+
118+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
119+
120+ select {
121+ case ip := <-gotImage:
122+ require.Equal(t, uint64(2), ip.InputSeq)
123+ require.Equal(t, "image/png", ip.Mime)
124+ require.Equal(t, []byte("PNGDATA"), ip.Data)
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("MsgImagePush was not sent")
127+ }
128+ require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
129+}
130+
131+func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
132+ clip := portsmocks.NewMockClipboardReader(t)
133+ clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
134+
135+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
136+
137+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138+}
139+
140+func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141+ huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142+ clip := portsmocks.NewMockClipboardReader(t)
143+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
144+
145+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
146+
147+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
148+}
149+
150+func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
151+ // A MockClipboardReader with no .EXPECT() set fails the test if
152+ // ReadImage is ever called — which must never happen for a 0x16 that
153+ // arrives as part of pasted content, even when the whole paste (open
154+ // marker, content, close marker) lands in a single read alongside it.
155+ clip := portsmocks.NewMockClipboardReader(t)
156+
157+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
158+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
159+
160+ require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
161+ select {
162+ case ip := <-gotImage:
163+ t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
164+ default:
165+ }
166+}
167+
168+func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
169+ // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
170+ // is ever called, which is exactly what a local (non-remote) attach must
171+ // never do.
172+ clip := portsmocks.NewMockClipboardReader(t)
173+
174+ gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
175+
176+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
177+}
@@ -0,0 +1,177 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// clipboardRunTestDeps wires a Run() attempt with an input reader that
21+// delivers data then blocks (so the pump stays parked until detach), a
22+// transport that captures every MsgInput/MsgImagePush send, and a Recv that
23+// answers Welcome then, once triggered, Detached — mirroring the pattern used
24+// by the OSC-color-response test elsewhere in this package.
25+func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
26+ t.Helper()
27+ var out bytes.Buffer
28+ var restoreCount int32
29+ resizeCh := make(chan domain.Size)
30+ input := newOneShotBlockingReader(stdin)
31+ defer input.unblock()
32+
33+ tm := portsmocks.NewMockTerminal(t)
34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
35+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36+ tm.EXPECT().In().Return(input).Maybe()
37+ tm.EXPECT().Out().Return(&out).Maybe()
38+ tm.EXPECT().Flush().Return(nil).Maybe()
39+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
40+
41+ gotInput = make(chan []byte, 8)
42+ gotImage = make(chan ports.ImagePush, 1)
43+ allowDetach := make(chan struct{})
44+
45+ tr := portsmocks.NewMockTransport(t)
46+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
47+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
48+ in, err := ports.UnmarshalInput(f.Payload)
49+ require.NoError(t, err)
50+ gotInput <- append([]byte(nil), in.Data...)
51+ if bytes.IndexByte(in.Data, triggerByte) >= 0 {
52+ closeOnce(allowDetach)
53+ }
54+ return nil
55+ }).Maybe()
56+ tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
57+ ip, err := ports.UnmarshalImagePush(f.Payload)
58+ require.NoError(t, err)
59+ gotImage <- ip
60+ return nil
61+ }).Maybe()
62+
63+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
64+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
65+ recvCh := make(chan recvItem, 1)
66+ recvCh <- recvItem{f: welcome}
67+ closed := make(chan struct{})
68+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
69+ select {
70+ case it := <-recvCh:
71+ return it.f, it.err
72+ case <-allowDetach:
73+ select {
74+ case <-closed:
75+ return ports.Frame{}, io.EOF
76+ default:
77+ close(closed)
78+ return detached, nil
79+ }
80+ case <-closed:
81+ return ports.Frame{}, io.EOF
82+ }
83+ }).Maybe()
84+ tr.EXPECT().Close().Return(nil).Once()
85+
86+ d := portsmocks.NewMockDialer(t)
87+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
88+
89+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
90+ require.NoError(t, err)
91+ return gotInput, gotImage
92+}
93+
94+func closeOnce(ch chan struct{}) {
95+ select {
96+ case <-ch:
97+ default:
98+ close(ch)
99+ }
100+}
101+
102+func drainInput(gotInput chan []byte) []byte {
103+ var b []byte
104+ for {
105+ select {
106+ case chunk := <-gotInput:
107+ b = append(b, chunk...)
108+ default:
109+ return b
110+ }
111+ }
112+}
113+
114+func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
115+ clip := portsmocks.NewMockClipboardReader(t)
116+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
117+
118+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
119+
120+ select {
121+ case ip := <-gotImage:
122+ require.Equal(t, uint64(2), ip.InputSeq)
123+ require.Equal(t, "image/png", ip.Mime)
124+ require.Equal(t, []byte("PNGDATA"), ip.Data)
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("MsgImagePush was not sent")
127+ }
128+ require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
129+}
130+
131+func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
132+ clip := portsmocks.NewMockClipboardReader(t)
133+ clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
134+
135+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
136+
137+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138+}
139+
140+func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141+ huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142+ clip := portsmocks.NewMockClipboardReader(t)
143+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
144+
145+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
146+
147+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
148+}
149+
150+func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
151+ // A MockClipboardReader with no .EXPECT() set fails the test if
152+ // ReadImage is ever called — which must never happen for a 0x16 that
153+ // arrives as part of pasted content, even when the whole paste (open
154+ // marker, content, close marker) lands in a single read alongside it.
155+ clip := portsmocks.NewMockClipboardReader(t)
156+
157+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
158+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
159+
160+ require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
161+ select {
162+ case ip := <-gotImage:
163+ t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
164+ default:
165+ }
166+}
167+
168+func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
169+ // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
170+ // is ever called, which is exactly what a local (non-remote) attach must
171+ // never do.
172+ clip := portsmocks.NewMockClipboardReader(t)
173+
174+ gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
175+
176+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
177+}
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
@@ -486,6 +486,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
486486 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
487487 d.applyTheme(sess, ac, th)
488488 }
489+ case ports.MsgImagePush:
490+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
491+ d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
492+ }
489493 case ports.MsgDetach:
490494 d.clientGone(sess, ac, tr, true)
491495 return
@@ -486,6 +486,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
486 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {486 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
487 d.applyTheme(sess, ac, th)487 d.applyTheme(sess, ac, th)
488 }488 }
489+ case ports.MsgImagePush:
490+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
491+ d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
492+ }
489 case ports.MsgDetach:493 case ports.MsgDetach:
490 d.clientGone(sess, ac, tr, true)494 d.clientGone(sess, ac, tr, true)
491 return495 return
M internal/usecase/daemon/clipboard.go +127 -0
@@ -1,9 +1,136 @@
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.
14 package daemon
25
36 import (
7+ "os"
8+
9+ "github.com/bnema/vev/internal/ports"
410 scopy "github.com/bnema/vev/internal/usecase/copy"
511 )
612
13+// maxImagePushSize independently caps an accepted MsgImagePush payload,
14+// defending against an old or foreign client even though the client is
15+// expected to enforce the same cap before sending. Kept at 1 MiB so one
16+// ImagePush fits the datagram transport's fragmented payload ceiling.
17+const maxImagePushSize = 1 << 20 // 1 MiB
18+
19+// Bracketed-paste markers used to wrap the injected clip path when the
20+// focused pane has DEC private mode 2004 enabled. Mirrors the client's
21+// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
22+// separate copy since the daemon has no dependency on the client package.
23+var (
24+ clipPasteOpenMarker = []byte("\x1b[200~")
25+ clipPasteCloseMarker = []byte("\x1b[201~")
26+)
27+
28+// handleSequencedImagePush routes an ImagePush through the same input-sequence
29+// surface as ordinary keystrokes. Echo prediction is still conservative; the
30+// sequence is carried so resume/dedup plumbing can order image pushes with
31+// surrounding input when that state reader grows beyond ordinary input.
32+func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33+ d.handleImagePush(sess, ip)
34+}
35+
36+// handleImagePush writes an ImagePush's bytes to a temp file and injects the
37+// file's path into the session's focused pane, as if typed/pasted there.
38+// Oversized or empty payloads are rejected without panicking. A write
39+// failure is logged and the push is dropped: the client has already
40+// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
41+// to re-trigger from here.
42+func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
43+ if len(ip.Data) == 0 {
44+ return
45+ }
46+ if len(ip.Data) > maxImagePushSize {
47+ d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
48+ return
49+ }
50+ path, err := d.writeClipboardImage(sess, ip)
51+ if err != nil {
52+ d.log.Error("writing clipboard image failed", "err", err)
53+ return
54+ }
55+ d.injectClipboardPath(sess, path)
56+}
57+
58+// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
59+// the path on sess for best-effort cleanup at session end (killSession).
60+func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
61+ dir := d.tempDir
62+ if dir == "" {
63+ dir = os.TempDir()
64+ }
65+ f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
66+ if err != nil {
67+ return "", err
68+ }
69+ path := f.Name()
70+ if _, err := f.Write(ip.Data); err != nil {
71+ _ = f.Close()
72+ _ = os.Remove(path)
73+ return "", err
74+ }
75+ if err := f.Close(); err != nil {
76+ _ = os.Remove(path)
77+ return "", err
78+ }
79+ sess.mu.Lock()
80+ sess.clipFiles = append(sess.clipFiles, path)
81+ sess.mu.Unlock()
82+ return path, nil
83+}
84+
85+// injectClipboardPath writes path's text into sess's focused pane, through
86+// the same PTY write path as ordinary input, wrapped in bracketed-paste
87+// markers iff the pane's Screen reports bracketed-paste mode enabled.
88+func (d *Daemon) injectClipboardPath(sess *session, path string) {
89+ tb := sess.activeTab()
90+ if tb == nil {
91+ return
92+ }
93+ tb.mu.Lock()
94+ p := tb.focusedPane()
95+ tb.mu.Unlock()
96+ if p == nil {
97+ return
98+ }
99+
100+ p.mu.Lock()
101+ bracketed := p.screen.BracketedPasteMode()
102+ p.mu.Unlock()
103+
104+ data := []byte(path)
105+ if bracketed {
106+ wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
107+ wrapped = append(wrapped, clipPasteOpenMarker...)
108+ wrapped = append(wrapped, data...)
109+ wrapped = append(wrapped, clipPasteCloseMarker...)
110+ data = wrapped
111+ }
112+ d.writeToPane(sess, p, data)
113+}
114+
115+// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
116+// types fall back to "bin" rather than rejecting the push: the daemon copies
117+// bytes as-is regardless (see the design doc's "no image conversion" scope
118+// decision).
119+func clipboardExt(mime string) string {
120+ switch mime {
121+ case "image/png":
122+ return "png"
123+ case "image/jpeg":
124+ return "jpg"
125+ case "image/webp":
126+ return "webp"
127+ case "image/gif":
128+ return "gif"
129+ default:
130+ return "bin"
131+ }
132+}
133+
7134 type clipboardForward struct {
8135 ac *attachedClient
9136 seq []byte
@@ -1,9 +1,136 @@
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.
1 package daemon4 package daemon
2 5
3 import (6 import (
7+ "os"
8+
9+ "github.com/bnema/vev/internal/ports"
4 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
5 )11 )
6 12
13+// maxImagePushSize independently caps an accepted MsgImagePush payload,
14+// defending against an old or foreign client even though the client is
15+// expected to enforce the same cap before sending. Kept at 1 MiB so one
16+// ImagePush fits the datagram transport's fragmented payload ceiling.
17+const maxImagePushSize = 1 << 20 // 1 MiB
18+
19+// Bracketed-paste markers used to wrap the injected clip path when the
20+// focused pane has DEC private mode 2004 enabled. Mirrors the client's
21+// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
22+// separate copy since the daemon has no dependency on the client package.
23+var (
24+ clipPasteOpenMarker = []byte("\x1b[200~")
25+ clipPasteCloseMarker = []byte("\x1b[201~")
26+)
27+
28+// handleSequencedImagePush routes an ImagePush through the same input-sequence
29+// surface as ordinary keystrokes. Echo prediction is still conservative; the
30+// sequence is carried so resume/dedup plumbing can order image pushes with
31+// surrounding input when that state reader grows beyond ordinary input.
32+func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33+ d.handleImagePush(sess, ip)
34+}
35+
36+// handleImagePush writes an ImagePush's bytes to a temp file and injects the
37+// file's path into the session's focused pane, as if typed/pasted there.
38+// Oversized or empty payloads are rejected without panicking. A write
39+// failure is logged and the push is dropped: the client has already
40+// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
41+// to re-trigger from here.
42+func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
43+ if len(ip.Data) == 0 {
44+ return
45+ }
46+ if len(ip.Data) > maxImagePushSize {
47+ d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
48+ return
49+ }
50+ path, err := d.writeClipboardImage(sess, ip)
51+ if err != nil {
52+ d.log.Error("writing clipboard image failed", "err", err)
53+ return
54+ }
55+ d.injectClipboardPath(sess, path)
56+}
57+
58+// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
59+// the path on sess for best-effort cleanup at session end (killSession).
60+func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
61+ dir := d.tempDir
62+ if dir == "" {
63+ dir = os.TempDir()
64+ }
65+ f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
66+ if err != nil {
67+ return "", err
68+ }
69+ path := f.Name()
70+ if _, err := f.Write(ip.Data); err != nil {
71+ _ = f.Close()
72+ _ = os.Remove(path)
73+ return "", err
74+ }
75+ if err := f.Close(); err != nil {
76+ _ = os.Remove(path)
77+ return "", err
78+ }
79+ sess.mu.Lock()
80+ sess.clipFiles = append(sess.clipFiles, path)
81+ sess.mu.Unlock()
82+ return path, nil
83+}
84+
85+// injectClipboardPath writes path's text into sess's focused pane, through
86+// the same PTY write path as ordinary input, wrapped in bracketed-paste
87+// markers iff the pane's Screen reports bracketed-paste mode enabled.
88+func (d *Daemon) injectClipboardPath(sess *session, path string) {
89+ tb := sess.activeTab()
90+ if tb == nil {
91+ return
92+ }
93+ tb.mu.Lock()
94+ p := tb.focusedPane()
95+ tb.mu.Unlock()
96+ if p == nil {
97+ return
98+ }
99+
100+ p.mu.Lock()
101+ bracketed := p.screen.BracketedPasteMode()
102+ p.mu.Unlock()
103+
104+ data := []byte(path)
105+ if bracketed {
106+ wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
107+ wrapped = append(wrapped, clipPasteOpenMarker...)
108+ wrapped = append(wrapped, data...)
109+ wrapped = append(wrapped, clipPasteCloseMarker...)
110+ data = wrapped
111+ }
112+ d.writeToPane(sess, p, data)
113+}
114+
115+// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
116+// types fall back to "bin" rather than rejecting the push: the daemon copies
117+// bytes as-is regardless (see the design doc's "no image conversion" scope
118+// decision).
119+func clipboardExt(mime string) string {
120+ switch mime {
121+ case "image/png":
122+ return "png"
123+ case "image/jpeg":
124+ return "jpg"
125+ case "image/webp":
126+ return "webp"
127+ case "image/gif":
128+ return "gif"
129+ default:
130+ return "bin"
131+ }
132+}
133+
7 type clipboardForward struct {134 type clipboardForward struct {
8 ac *attachedClient135 ac *attachedClient
9 seq []byte136 seq []byte
M internal/usecase/daemon/clipboard_test.go +142 -0
@@ -4,6 +4,8 @@ import (
44 "context"
55 "encoding/base64"
66 "io"
7+ "os"
8+ "path/filepath"
79 "strings"
810 "testing"
911 "time"
@@ -17,6 +19,146 @@ import (
1719 "github.com/bnema/vev/pkg/renderer"
1820 )
1921
22+func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
23+ writes := make(chan []byte, 1)
24+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
25+ defer releasePTY()
26+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
27+ d.tempDir = t.TempDir()
28+
29+ data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
30+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
31+
32+ injected := <-writes
33+ path := string(injected)
34+ require.FileExists(t, path)
35+ require.Equal(t, ".png", filepath.Ext(path))
36+ require.True(t, filepath.IsAbs(path))
37+
38+ got, err := os.ReadFile(path)
39+ require.NoError(t, err)
40+ require.Equal(t, data, got)
41+
42+ info, err := os.Stat(path)
43+ require.NoError(t, err)
44+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
45+}
46+
47+func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
48+ writes := make(chan []byte, 1)
49+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
50+ defer releasePTY()
51+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
52+ d.tempDir = t.TempDir()
53+ pane := sess.tabs[0].focusedPane()
54+ require.False(t, pane.screen.BracketedPasteMode())
55+
56+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
57+
58+ injected := <-writes
59+ require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
60+ require.FileExists(t, string(injected))
61+}
62+
63+func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
64+ writes := make(chan []byte, 1)
65+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
66+ defer releasePTY()
67+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
68+ d.tempDir = t.TempDir()
69+ pane := sess.tabs[0].focusedPane()
70+ pane.screen.Write([]byte("\x1b[?2004h"))
71+ require.True(t, pane.screen.BracketedPasteMode())
72+
73+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
74+
75+ injected := <-writes
76+ require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
77+ require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
78+ require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
79+ path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
80+ require.FileExists(t, path)
81+}
82+
83+func hasBracketedPasteMarkers(b []byte) bool {
84+ return len(b) >= len(clipPasteOpenMarker) &&
85+ string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
86+}
87+
88+func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
89+ writes := make(chan []byte, 1)
90+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
91+ defer releasePTY()
92+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
93+ dir := t.TempDir()
94+ d.tempDir = dir
95+
96+ huge := make([]byte, maxImagePushSize+1)
97+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
98+
99+ select {
100+ case w := <-writes:
101+ t.Fatalf("oversized push must not be injected into the pane, got %q", w)
102+ default:
103+ }
104+ entries, err := os.ReadDir(dir)
105+ require.NoError(t, err)
106+ require.Empty(t, entries, "oversized push must not write a temp file")
107+}
108+
109+func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
110+ writes := make(chan []byte, 1)
111+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
112+ defer releasePTY()
113+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
114+ dir := t.TempDir()
115+ d.tempDir = dir
116+
117+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
118+
119+ select {
120+ case w := <-writes:
121+ t.Fatalf("empty push must not be injected, got %q", w)
122+ default:
123+ }
124+ entries, err := os.ReadDir(dir)
125+ require.NoError(t, err)
126+ require.Empty(t, entries)
127+}
128+
129+func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
130+ cases := map[string]string{
131+ "image/png": "png",
132+ "image/jpeg": "jpg",
133+ "image/webp": "webp",
134+ "image/gif": "gif",
135+ "image/bmp": "bin",
136+ "": "bin",
137+ "application/octet-stream": "bin",
138+ }
139+ for mime, want := range cases {
140+ require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
141+ }
142+}
143+
144+func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
145+ writes := make(chan []byte, 1)
146+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
147+ defer releasePTY()
148+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
149+ d.tempDir = t.TempDir()
150+ // killSession requires the session to be registered in the daemon's
151+ // registry (newManualSessionWithPTYs already does this via d.sessions).
152+
153+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
154+ injected := <-writes
155+ path := string(injected)
156+ require.FileExists(t, path)
157+
158+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
159+ require.NoFileExists(t, path)
160+}
161+
20162 // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
21163 // reports io.EOF — driving ptyReader through exactly len(chunks) reads before
22164 // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "io"6 "io"
7+ "os"
8+ "path/filepath"
7 "strings"9 "strings"
8 "testing"10 "testing"
9 "time"11 "time"
@@ -17,6 +19,146 @@ import (
17 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
18 )20 )
19 21
22+func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
23+ writes := make(chan []byte, 1)
24+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
25+ defer releasePTY()
26+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
27+ d.tempDir = t.TempDir()
28+
29+ data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
30+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
31+
32+ injected := <-writes
33+ path := string(injected)
34+ require.FileExists(t, path)
35+ require.Equal(t, ".png", filepath.Ext(path))
36+ require.True(t, filepath.IsAbs(path))
37+
38+ got, err := os.ReadFile(path)
39+ require.NoError(t, err)
40+ require.Equal(t, data, got)
41+
42+ info, err := os.Stat(path)
43+ require.NoError(t, err)
44+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
45+}
46+
47+func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
48+ writes := make(chan []byte, 1)
49+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
50+ defer releasePTY()
51+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
52+ d.tempDir = t.TempDir()
53+ pane := sess.tabs[0].focusedPane()
54+ require.False(t, pane.screen.BracketedPasteMode())
55+
56+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
57+
58+ injected := <-writes
59+ require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
60+ require.FileExists(t, string(injected))
61+}
62+
63+func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
64+ writes := make(chan []byte, 1)
65+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
66+ defer releasePTY()
67+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
68+ d.tempDir = t.TempDir()
69+ pane := sess.tabs[0].focusedPane()
70+ pane.screen.Write([]byte("\x1b[?2004h"))
71+ require.True(t, pane.screen.BracketedPasteMode())
72+
73+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
74+
75+ injected := <-writes
76+ require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
77+ require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
78+ require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
79+ path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
80+ require.FileExists(t, path)
81+}
82+
83+func hasBracketedPasteMarkers(b []byte) bool {
84+ return len(b) >= len(clipPasteOpenMarker) &&
85+ string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
86+}
87+
88+func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
89+ writes := make(chan []byte, 1)
90+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
91+ defer releasePTY()
92+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
93+ dir := t.TempDir()
94+ d.tempDir = dir
95+
96+ huge := make([]byte, maxImagePushSize+1)
97+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
98+
99+ select {
100+ case w := <-writes:
101+ t.Fatalf("oversized push must not be injected into the pane, got %q", w)
102+ default:
103+ }
104+ entries, err := os.ReadDir(dir)
105+ require.NoError(t, err)
106+ require.Empty(t, entries, "oversized push must not write a temp file")
107+}
108+
109+func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
110+ writes := make(chan []byte, 1)
111+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
112+ defer releasePTY()
113+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
114+ dir := t.TempDir()
115+ d.tempDir = dir
116+
117+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
118+
119+ select {
120+ case w := <-writes:
121+ t.Fatalf("empty push must not be injected, got %q", w)
122+ default:
123+ }
124+ entries, err := os.ReadDir(dir)
125+ require.NoError(t, err)
126+ require.Empty(t, entries)
127+}
128+
129+func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
130+ cases := map[string]string{
131+ "image/png": "png",
132+ "image/jpeg": "jpg",
133+ "image/webp": "webp",
134+ "image/gif": "gif",
135+ "image/bmp": "bin",
136+ "": "bin",
137+ "application/octet-stream": "bin",
138+ }
139+ for mime, want := range cases {
140+ require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
141+ }
142+}
143+
144+func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
145+ writes := make(chan []byte, 1)
146+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
147+ defer releasePTY()
148+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
149+ d.tempDir = t.TempDir()
150+ // killSession requires the session to be registered in the daemon's
151+ // registry (newManualSessionWithPTYs already does this via d.sessions).
152+
153+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
154+ injected := <-writes
155+ path := string(injected)
156+ require.FileExists(t, path)
157+
158+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
159+ require.NoFileExists(t, path)
160+}
161+
20 // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then162 // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
21 // reports io.EOF — driving ptyReader through exactly len(chunks) reads before163 // reports io.EOF — driving ptyReader through exactly len(chunks) reads before
22 // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.164 // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
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,7 +48,7 @@ type session struct {
4748 ctx context.Context
4849 cancel context.CancelFunc
4950
50- mu sync.Mutex // guards tabs, active, client, and clipboard queue state
51+ mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
5152 tabs []*tab
5253 active int
5354 client *attachedClient
@@ -55,6 +56,10 @@ type session struct {
5556 clipboardWorkerRunning bool
5657 cwd string
5758 createdAt int64
59+ // clipFiles records clipboard-image-transfer temp file paths (see
60+ // clipboard.go) written for this session, removed best-effort in
61+ // killSession.
62+ clipFiles []string
5863 }
5964
6065 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -484,11 +489,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
484489 sess.cancel()
485490 sess.mu.Lock()
486491 tabs := append([]*tab(nil), sess.tabs...)
492+ clipFiles := sess.clipFiles
493+ sess.clipFiles = nil
487494 sess.mu.Unlock()
488495 for _, tb := range tabs {
489496 d.clearDestroyedTabPreview(tb)
490497 tb.closeAllPanes()
491498 }
499+ for _, path := range clipFiles {
500+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
501+ d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
502+ }
503+ }
492504 if empty {
493505 d.doneOnce.Do(func() { close(d.done) })
494506 } 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,7 +48,7 @@ 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, client, and clipboard queue state51+ mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
51 tabs []*tab52 tabs []*tab
52 active int53 active int
53 client *attachedClient54 client *attachedClient
@@ -55,6 +56,10 @@ type session struct {
55 clipboardWorkerRunning bool56 clipboardWorkerRunning bool
56 cwd string57 cwd string
57 createdAt int6458 createdAt int64
59+ // clipFiles records clipboard-image-transfer temp file paths (see
60+ // clipboard.go) written for this session, removed best-effort in
61+ // killSession.
62+ clipFiles []string
58 }63 }
59 64
60 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.65 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -484,11 +489,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
484 sess.cancel()489 sess.cancel()
485 sess.mu.Lock()490 sess.mu.Lock()
486 tabs := append([]*tab(nil), sess.tabs...)491 tabs := append([]*tab(nil), sess.tabs...)
492+ clipFiles := sess.clipFiles
493+ sess.clipFiles = nil
487 sess.mu.Unlock()494 sess.mu.Unlock()
488 for _, tb := range tabs {495 for _, tb := range tabs {
489 d.clearDestroyedTabPreview(tb)496 d.clearDestroyedTabPreview(tb)
490 tb.closeAllPanes()497 tb.closeAllPanes()
491 }498 }
499+ for _, path := range clipFiles {
500+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
501+ d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
502+ }
503+ }
492 if empty {504 if empty {
493 d.doneOnce.Do(func() { close(d.done) })505 d.doneOnce.Do(func() { close(d.done) })
494 } else if ringing {506 } 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