Repository: charmbracelet/bubbletea
Pull request #1086 (2024-08-15 19:42:44)

feat: support setting and querying the terminal clipboard using OSC52
This adds support to setting the system and primary (X11 & Wayland) clipboards using OSC52. This makes the clipboard commands work even on remote session such as SSH.

While this doesn't work on all terminals, most modern terminals support OSC52 including Alacritty, Kitty, Xterm.JS, etc. For terminals, that don't support OSC52, application developers should consider using a Golang clipboard library like https://github.com/atotto/clipboard.

OSC52 support can be detected if the terminal responds to a `ReadClipboard` command.

Fixes: https://github.com/charmbracelet/bubbletea/issues/982


Patch:
diff --git a/clipboard.go b/clipboard.go
index 9963b34..cbc8847 100644
--- a/clipboard.go
+++ b/clipboard.go
@@ -1,11 +1,69 @@
 package tea
 
-// ClipboardMsg is a clipboard read message event.
-// This message is emitted when a terminal receives an OSC52 clipboard read
-// message event.
+// ClipboardMsg is a clipboard read message event. This message is emitted when
+// a terminal receives an OSC52 clipboard read message event.
 type ClipboardMsg string
 
 // String returns the string representation of the clipboard message.
 func (e ClipboardMsg) String() string {
 	return string(e)
 }
+
+// PrimaryClipboardMsg is a primary clipboard read message event. This message
+// is emitted when a terminal receives an OSC52 primary clipboard read message
+// event. Primary clipboard selection is a feature present in X11 and Wayland
+// only.
+type PrimaryClipboardMsg string
+
+// String returns the string representation of the primary clipboard message.
+func (e PrimaryClipboardMsg) String() string {
+	return string(e)
+}
+
+// setClipboardMsg is an internal message used to set the system clipboard
+// using OSC52.
+type setClipboardMsg string
+
+// SetClipboard produces a command that sets the system clipboard using OSC52.
+// Note that OSC52 is not supported in all terminals.
+func SetClipboard(s string) Cmd {
+	return func() Msg {
+		return setClipboardMsg(s)
+	}
+}
+
+// readClipboardMsg is an internal message used to read the system clipboard
+// using OSC52.
+type readClipboardMsg struct{}
+
+// ReadClipboard produces a command that reads the system clipboard using OSC52.
+// Note that OSC52 is not supported in all terminals.
+func ReadClipboard() Msg {
+	return readClipboardMsg{}
+}
+
+// setPrimaryClipboardMsg is an internal message used to set the primary
+// clipboard using OSC52.
+type setPrimaryClipboardMsg string
+
+// SetPrimaryClipboard produces a command that sets the primary clipboard using
+// OSC52. Primary clipboard selection is a feature present in X11 and Wayland
+// only.
+// Note that OSC52 is not supported in all terminals.
+func SetPrimaryClipboard(s string) Cmd {
+	return func() Msg {
+		return setPrimaryClipboardMsg(s)
+	}
+}
+
+// readPrimaryClipboardMsg is an internal message used to read the primary
+// clipboard using OSC52.
+type readPrimaryClipboardMsg struct{}
+
+// ReadPrimaryClipboard produces a command that reads the primary clipboard
+// using OSC52. Primary clipboard selection is a feature present in X11 and
+// Wayland only.
+// Note that OSC52 is not supported in all terminals.
+func ReadPrimaryClipboard() Msg {
+	return readPrimaryClipboardMsg{}
+}
diff --git a/parse.go b/parse.go
index 600ed25..0981484 100644
--- a/parse.go
+++ b/parse.go
@@ -581,15 +581,28 @@ func parseOsc(b []byte) (int, Msg) {
 		if len(parts) == 0 {
 			return i, ClipboardMsg("")
 		}
-		b64 := parts[len(parts)-1]
+		if len(parts) != 2 {
+			break
+		}
+
+		b64 := parts[1]
 		bts, err := base64.StdEncoding.DecodeString(b64)
-		if err != nil {
-			return i, ClipboardMsg("")
+
+		switch parts[0] {
+		case "c":
+			if err != nil {
+				return i, ClipboardMsg("")
+			}
+			return i, ClipboardMsg(string(bts))
+		case "p":
+			if err != nil {
+				return i, PrimaryClipboardMsg("")
+			}
+			return i, PrimaryClipboardMsg(string(bts))
 		}
-		return i, ClipboardMsg(bts)
-	default:
-		return i, UnknownMsg(b[:i])
 	}
+
+	return i, UnknownMsg(b[:i])
 }
 
 // parseStTerminated parses a control sequence that gets terminated by a ST character.
diff --git a/tea.go b/tea.go
index f75fd71..5c2ec4c 100644
--- a/tea.go
+++ b/tea.go
@@ -400,7 +400,19 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				p.renderer.execute(ansi.DisableBracketedPaste)
 				p.bpActive = false
 
-			case setBackgroundColorMsg:
+			case readClipboardMsg:
+				p.renderer.execute(ansi.RequestSystemClipboard)
+
+			case setClipboardMsg:
+				p.renderer.execute(ansi.SetSystemClipboard(string(msg)))
+
+			case readPrimaryClipboardMsg:
+				p.renderer.execute(ansi.RequestPrimaryClipboard)
+
+			case setPrimaryClipboardMsg:
+				p.renderer.execute(ansi.SetPrimaryClipboard(string(msg)))
+
+      case setBackgroundColorMsg:
 				if msg.Color != nil {
 					p.renderer.execute(ansi.SetBackgroundColor(msg.Color))
 				}


Test patch:
diff --git a/screen_test.go b/screen_test.go
index f686a89..79e4b95 100644
--- a/screen_test.go
+++ b/screen_test.go
@@ -58,7 +58,12 @@ func TestClearMsg(t *testing.T) {
 			expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
-			name:     "bg_fg_cur_color",
+			name:     "read_set_clipboard",
+			cmds:     []Cmd{ReadClipboard, SetClipboard("success")},
+			expected: "\x1b[?25l\x1b[?2004h\x1b]52;c;?\a\x1b]52;c;c3VjY2Vzcw==\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+    },
+		{
+      name:     "bg_fg_cur_color",
 			cmds:     []Cmd{ForegroundColor, BackgroundColor, CursorColor},
 			expected: "\x1b[?25l\x1b[?2004h\x1b]10;?\a\x1b]11;?\a\x1b]12;?\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},