Skip to content

Commit

Permalink
feat: support setting and querying the terminal clipboard using OSC52
Browse files Browse the repository at this point in the history
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: #982
  • Loading branch information
aymanbagabas committed Aug 15, 2024
1 parent eb2eee4 commit b97ffd7
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 9 deletions.
63 changes: 60 additions & 3 deletions clipboard.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,68 @@
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.
// Note that the 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.
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.
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.
// Note that the primary clipboard selection is a feature present in X11 and
// Wayland only.
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.
// Note that the primary clipboard selection is a feature present in X11 and
// Wayland only.
func ReadPrimaryClipboard() Msg {
return readPrimaryClipboardMsg{}
}
25 changes: 19 additions & 6 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Check failure on line 584 in parse.go

View workflow job for this annotation

GitHub Actions / lint-soft

Magic number: 2, in <condition> detected (gomnd)
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.
Expand Down
5 changes: 5 additions & 0 deletions screen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func TestClearMsg(t *testing.T) {
cmds: []Cmd{DisableBracketedPaste, EnableBracketedPaste},
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: "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",
},
}

for _, test := range tests {
Expand Down
12 changes: 12 additions & 0 deletions tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
p.renderer.execute(ansi.DisableBracketedPaste)
p.bpActive = false

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 execMsg:
// NB: this blocks.
p.exec(msg.cmd, msg.fn)
Expand Down

0 comments on commit b97ffd7

Please sign in to comment.