Skip to content

Commit 0d34053

Browse files
authored
tui: fix clipboard over SSH using OSC52 escape sequences (#656)
## Problem When `roborev tui` is used over an SSH connection, pressing `y` to copy a review shows "Copied to clipboard ✓" but the clipboard is empty on the local machine. **Root cause:** `realClipboard.WriteText()` calls `xclip`/`xsel`/`wl-clipboard` on the *remote* machine. This only reaches the user's local clipboard if X11 forwarding is active. Terminals that don't forward X11 (e.g. Tabby's built-in SSH plugin) silently lose the data. ## Fix Add an `osc52Clipboard` implementation that writes the [OSC52 terminal escape sequence](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) (`\x1b]52;c;<base64>\x07`) to stderr. The sequence travels through the SSH pipe and is intercepted by the **local** terminal emulator, which sets the system clipboard directly — no X11 forwarding required. `newClipboard()` auto-selects the implementation: - **SSH session** (`SSH_TTY` / `SSH_CLIENT` / `SSH_CONNECTION` env var is set) → `osc52Clipboard` - **Local session** → `realClipboard` (unchanged behaviour) ## Compatibility OSC52 is supported by all modern terminals: Alacritty, Tabby (xterm.js), Kitty, WezTerm, iTerm2, Windows Terminal, and more. ## Changes - `cmd/roborev/tui/types.go` — add `osc52Clipboard` struct; promote `go-osc52/v2` from indirect to direct dependency - `cmd/roborev/tui/tui.go` — add `newClipboard()` factory with SSH detection; wire into `newModel` - `cmd/roborev/tui/review_clipboard_test.go` — 5 new tests: OSC52 output format, empty string, SSH env var detection (SSH_TTY / SSH_CLIENT / SSH_CONNECTION / no-SSH) Co-authored-by: Luis Gonzalez <lgonzalezsa@users.noreply.github.com>
1 parent 74fac25 commit 0d34053

3 files changed

Lines changed: 98 additions & 1 deletion

File tree

cmd/roborev/tui/review_clipboard_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"bytes"
45
"fmt"
56
"net/http"
67
"testing"
@@ -468,3 +469,73 @@ func TestFormatClipboardContentNoResponses(t *testing.T) {
468469

469470
assert.NotContains(t, withNil, "Comments")
470471
}
472+
473+
func TestOSC52ClipboardWritesEscapeSequence(t *testing.T) {
474+
var buf bytes.Buffer
475+
cb := &osc52Clipboard{output: &buf}
476+
477+
err := cb.WriteText("hello clipboard")
478+
require.NoError(t, err)
479+
480+
output := buf.String()
481+
// OSC52 sequences begin with ESC ] 52 ; and end with BEL or ST
482+
assert.Contains(t, output, "\x1b]52;")
483+
// The base64-encoded content of "hello clipboard" is aGVsbG8gY2xpcGJvYXJk
484+
assert.Contains(t, output, "aGVsbG8gY2xpcGJvYXJk")
485+
}
486+
487+
func TestOSC52ClipboardEmptyText(t *testing.T) {
488+
var buf bytes.Buffer
489+
cb := &osc52Clipboard{output: &buf}
490+
491+
err := cb.WriteText("")
492+
require.NoError(t, err)
493+
// An empty string still produces a valid OSC52 sequence (clears clipboard)
494+
assert.Contains(t, buf.String(), "\x1b]52;")
495+
}
496+
497+
func TestNewClipboardReturnsOSC52OverSSH(t *testing.T) {
498+
tests := []struct {
499+
name string
500+
envVars map[string]string
501+
wantOSC bool
502+
}{
503+
{
504+
name: "SSH_TTY set",
505+
envVars: map[string]string{"SSH_TTY": "/dev/pts/0"},
506+
wantOSC: true,
507+
},
508+
{
509+
name: "SSH_CLIENT set",
510+
envVars: map[string]string{"SSH_CLIENT": "192.168.1.1 50000 22"},
511+
wantOSC: true,
512+
},
513+
{
514+
name: "SSH_CONNECTION set",
515+
envVars: map[string]string{"SSH_CONNECTION": "192.168.1.1 50000 10.0.0.1 22"},
516+
wantOSC: true,
517+
},
518+
{
519+
name: "no SSH env vars",
520+
envVars: map[string]string{},
521+
wantOSC: false,
522+
},
523+
}
524+
525+
for _, tt := range tests {
526+
t.Run(tt.name, func(t *testing.T) {
527+
// Clear SSH env vars before each sub-test
528+
for _, key := range []string{"SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"} {
529+
t.Setenv(key, "")
530+
}
531+
for k, v := range tt.envVars {
532+
t.Setenv(k, v)
533+
}
534+
535+
cb := newClipboard()
536+
_, isOSC52 := cb.(*osc52Clipboard)
537+
assert.Equal(t, tt.wantOSC, isOSC52,
538+
"expected osc52Clipboard=%v for env %v", tt.wantOSC, tt.envVars)
539+
})
540+
}
541+
}

cmd/roborev/tui/tui.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,18 @@ func isConnectionError(err error) bool {
447447
return errors.As(err, &netErr)
448448
}
449449

450+
// newClipboard returns the appropriate ClipboardWriter for the current environment.
451+
// When running over SSH (detected via SSH_TTY, SSH_CLIENT, or SSH_CONNECTION env vars),
452+
// it returns an osc52Clipboard that writes clipboard data through OSC52 terminal escape
453+
// sequences — this works without X11 forwarding and is supported by all modern terminals.
454+
// Otherwise, it falls back to realClipboard which uses the local system clipboard tools.
455+
func newClipboard() ClipboardWriter {
456+
if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CLIENT") != "" || os.Getenv("SSH_CONNECTION") != "" {
457+
return &osc52Clipboard{output: os.Stderr}
458+
}
459+
return &realClipboard{}
460+
}
461+
450462
func newModel(ep daemon.DaemonEndpoint, opts ...option) model {
451463
var opt options
452464
for _, o := range opts {
@@ -584,7 +596,7 @@ func newModel(ep daemon.DaemonEndpoint, opts ...option) model {
584596
branchNames: make(map[int64]string), // Cache derived branch names to avoid git calls on render
585597
pendingClosed: make(map[int64]pendingState), // Track pending closed changes (by job ID)
586598
pendingReviewClosed: make(map[int64]pendingState), // Track pending closed changes (by review ID)
587-
clipboard: &realClipboard{},
599+
clipboard: newClipboard(),
588600
mdCache: newMarkdownCache(tabWidth),
589601
tasksEnabled: tasksEnabled,
590602
mouseEnabled: mouseEnabled,

cmd/roborev/tui/types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package tui
22

33
import (
44
"errors"
5+
"io"
56
"time"
67

78
"github.com/atotto/clipboard"
9+
osc52 "github.com/aymanbagabas/go-osc52/v2"
810
"github.com/roborev-dev/roborev/internal/daemon"
911
"github.com/roborev-dev/roborev/internal/storage"
1012
"github.com/roborev-dev/roborev/internal/streamfmt"
@@ -275,6 +277,18 @@ func (r *realClipboard) WriteText(text string) error {
275277
return clipboard.WriteAll(text)
276278
}
277279

280+
// osc52Clipboard implements ClipboardWriter using OSC52 terminal escape sequences.
281+
// It writes clipboard data through the terminal emulator, which makes it work
282+
// correctly over SSH without requiring X11 forwarding.
283+
type osc52Clipboard struct {
284+
output io.Writer
285+
}
286+
287+
func (c *osc52Clipboard) WriteText(text string) error {
288+
_, err := osc52.New(text).WriteTo(c.output)
289+
return err
290+
}
291+
278292
// option func(*options) is a functional option for TUI.
279293
type option func(*options)
280294

0 commit comments

Comments
 (0)