Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions harness/clipboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Clipboard support for /copy: put text on the system clipboard from a terminal
// app, with no third-party dependency. Two independent paths are used so the
// text lands whether sesh runs locally or over SSH, and whether or not the
// terminal allows programmatic clipboard writes.
package harness

import (
"encoding/base64"
"fmt"
"os/exec"
"runtime"
"strings"
)

// osc52 is the terminal "set clipboard" escape sequence: the terminal itself
// base64-decodes the payload and stores it, so it reaches the clipboard even
// across an SSH hop. The "c" selection is the system clipboard.
func osc52(text string) string {
return "\033]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\007"
}

// setClipboard copies text by two independent paths so it lands in as many
// setups as possible: OSC 52 through the terminal (works over SSH, but some
// terminals and a default tmux drop it) and a local clipboard tool when one is
// on PATH (reliable locally and through tmux, but not over SSH). It reports
// which paths ran so the caller can tell the user, and warn when none did.
func setClipboard(text string) (tool string, osc bool) {
if t, ok := activeConsole.(*tuiConsole); ok {
t.mu.Lock()
fmt.Fprint(t.out, osc52(text)) // invisible to the terminal; leaves the footer alone
t.mu.Unlock()
osc = true
}
return localCopy(text), osc
}

// localCopy pipes text to the first platform clipboard tool found on PATH,
// returning its name, or "" when none is available or none succeeds.
func localCopy(text string) string {
for _, tool := range clipboardTools() {
if _, err := exec.LookPath(tool[0]); err != nil {
continue
}
cmd := exec.Command(tool[0], tool[1:]...)
cmd.Stdin = strings.NewReader(text)
if cmd.Run() == nil {
return tool[0]
}
}
return ""
}

// clipboardTools is the per-platform list of clipboard writers, in preference
// order. Each entry is the command and its args; stdin carries the text.
func clipboardTools() [][]string {
switch runtime.GOOS {
case "darwin":
return [][]string{{"pbcopy"}}
case "windows":
return [][]string{{"clip"}}
default: // linux, the BSDs
return [][]string{
{"wl-copy"}, // Wayland
{"xclip", "-selection", "clipboard"}, // X11
{"xsel", "--clipboard", "--input"}, // X11
{"clip.exe"}, // WSL bridge to the Windows clipboard
}
}
}
37 changes: 37 additions & 0 deletions harness/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package harness

import (
"testing"

"github.com/mike-diff/sesh/agent"
)

// TestLastAssistantText: /copy reaches back past trailing tool/user turns and
// past a tool-call-only assistant turn (which has no text) to the last response
// the user actually saw. Breaker: walk the history forward, or drop the
// non-empty check, and the wrong turn (or none) is copied.
func TestLastAssistantText(t *testing.T) {
r := &repl{history: []agent.Turn{
{Role: "user", Text: "first"},
{Role: "assistant", Text: "older answer"},
{Role: "user", Text: "second"},
{Role: "assistant", Text: "newer answer"},
{Role: "assistant", Text: ""}, // a tool-call-only turn carries no text
{Role: "tool"},
}}
if got := r.lastAssistantText(); got != "newer answer" {
t.Fatalf("lastAssistantText = %q, want %q", got, "newer answer")
}
if got := (&repl{}).lastAssistantText(); got != "" {
t.Fatalf("empty history must yield no text, got %q", got)
}
}

// TestOSC52 pins the clipboard escape sequence to the wire format terminals
// expect (a real external contract): ESC ] 52 ; c ; <base64> BEL. Breaker:
// wrong selection char, wrong terminator, or unencoded payload.
func TestOSC52(t *testing.T) {
if got := osc52("hi"); got != "\033]52;c;aGk=\007" {
t.Fatalf("osc52 = %q, want ESC]52;c;aGk=BEL", got)
}
}
1 change: 1 addition & 0 deletions harness/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ SESSION COMMANDS (interactive; tab completes)
/chain show this conversation's handoff chain and ledger
/compact summarize in place (lossier than /handoff)
/settings session settings picker: show thinking
/copy copy the last response to the clipboard (clean source)
/help command and key reference
exit, /exit, ctrl-d quit (prints sesh -resume <id>)

Expand Down
44 changes: 41 additions & 3 deletions harness/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ var slashCommands = []slashCommand{
{name: "/chain", run: func(r *repl, _ string) { r.chainCmd() }},
{name: "/compact", run: func(r *repl, _ string) { r.compactCmd() }},
{name: "/settings", run: func(r *repl, _ string) { r.settingsCmd() }},
{name: "/copy", run: func(r *repl, _ string) { r.copyCmd() }},
{name: "/help", run: func(r *repl, _ string) { r.helpCmd() }},
{name: "/exit", quit: true},
{name: "/quit", quit: true},
Expand Down Expand Up @@ -344,6 +345,42 @@ func (r *repl) contextCmd(arg string) {
emit("%s context window set to %s tokens%s; automatic handoff at 80%%%s\n\n", dim, kTokens(n), note, reset)
}

// copyCmd puts the last assistant response on the clipboard as its raw markdown
// source: clean by construction, free of the terminal's line wrapping and the
// renderer's indentation that a mouse-selected copy carries.
func (r *repl) copyCmd() {
text := r.lastAssistantText()
if text == "" {
emit("%s nothing to copy yet%s\n\n", dim, reset)
return
}
tool, osc := setClipboard(text)
var via string
switch {
case tool != "" && osc:
via = tool + " + OSC 52"
case tool != "":
via = tool
case osc:
via = "OSC 52"
default:
emit("%s no clipboard available: install wl-copy/xclip/xsel, or enable OSC 52 in your terminal%s\n\n", red, reset)
return
}
emit("%s copied last response to clipboard, %d lines (%s)%s\n\n", dim, 1+strings.Count(text, "\n"), via, reset)
}

// lastAssistantText returns the most recent assistant turn that carried text;
// turns that only made tool calls have none. Empty when nothing has been said.
func (r *repl) lastAssistantText() string {
for i := len(r.history) - 1; i >= 0; i-- {
if r.history[i].Role == "assistant" && r.history[i].Text != "" {
return r.history[i].Text
}
}
return ""
}

// settingsCmd opens the session-settings picker: arrows pick a setting, enter
// toggles it, and the menu reopens with the new value until cancelled. These
// are policies for THIS session; durable knobs live in files (providers.json,
Expand Down Expand Up @@ -395,11 +432,12 @@ func (r *repl) helpCmd() {
/chain show this conversation's handoff chain and its ledger
/compact summarize history in place (lossier than /handoff)
/settings session settings picker (show thinking)
/copy copy the last response to the clipboard (clean source)
/help this help
exit, /exit quit (ctrl-d works too); prints how to resume
keys: ctrl-c cancels the running turn (twice quits) · shift+enter or ctrl-j
inserts a newline · up/down history · tab completes · pastes over 3
lines collapse to a [snippet] sent in full
keys: ctrl-c cancels the running turn (twice quits) · shift+enter, ctrl-j, or
\+enter inserts a newline · up/down history · tab completes · pastes over
3 lines collapse to a [snippet] sent in full
config: ~/.sesh/ holds providers.json, credentials, SYSTEM.md, statusline
%s
`, dim, reset)
Expand Down
Loading