diff --git a/harness/clipboard.go b/harness/clipboard.go new file mode 100644 index 0000000..512dfa8 --- /dev/null +++ b/harness/clipboard.go @@ -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 + } + } +} diff --git a/harness/clipboard_test.go b/harness/clipboard_test.go new file mode 100644 index 0000000..eefd2f3 --- /dev/null +++ b/harness/clipboard_test.go @@ -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 ; 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) + } +} diff --git a/harness/help.go b/harness/help.go index 4ae9b5b..3634dc6 100644 --- a/harness/help.go +++ b/harness/help.go @@ -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 ) diff --git a/harness/repl.go b/harness/repl.go index 02075df..a3816fa 100644 --- a/harness/repl.go +++ b/harness/repl.go @@ -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}, @@ -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, @@ -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)