From 54ca1f1681c1b179fbdf3cb4ff7d042adacfaf4f Mon Sep 17 00:00:00 2001 From: Yu Yi Date: Tue, 23 Jun 2026 07:23:55 -0400 Subject: [PATCH] tui: /copy + Ctrl+Y copy an assistant reply's raw markdown to the clipboard (closes #362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an assistant reply out of the terminal dragged in glamour styling (ANSI codes, box-drawing, wrapped/indented lines), so the paste was mangled. The transcript already keeps each reply's raw markdown in transcriptItem.Text, separate from the glamour-rendered Rendered field — this plumbs that clean text to the system clipboard. - /copy [N]: copies the last assistant reply, or reply N (1-based over assistant turns), as raw markdown. Added to slashSpecs so it shows in /help and the / picker. - Ctrl+Y: the no-arg form of /copy, handled in handleInputKey before the pickers and textarea so it fires from any input state. - Delivery via OSC 52 (go-osc52, promoted to a direct dep) written to the tty — works locally and over SSH with no external binary, and doesn't fight bubbletea's alt-screen stdout renderer. - Pure pickCopyTarget keeps the numbering/bounds logic unit-testable. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 +++ README.md | 8 ++- cmd/glue/tui/clipboard.go | 79 +++++++++++++++++++++ cmd/glue/tui/clipboard_test.go | 118 +++++++++++++++++++++++++++++++ cmd/glue/tui/commands.go | 1 + cmd/glue/tui/slashpicker_test.go | 4 +- cmd/glue/tui/tui.go | 11 ++- go.mod | 2 +- 8 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 cmd/glue/tui/clipboard.go create mode 100644 cmd/glue/tui/clipboard_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e793c19..b6d98b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **TUI: `/copy [N]` + Ctrl+Y copy an assistant reply's raw markdown to + the system clipboard (`cmd/glue/tui`).** Selecting a reply out of the + terminal dragged in glamour styling (ANSI, box-drawing, wrapped lines), + so the paste was mangled. The transcript already keeps each reply's raw + markdown separate from its rendered form; `/copy` now sends that clean + text to the clipboard via an OSC 52 sequence — works locally and over + SSH with no external binary. Defaults to the last reply; `/copy 2` + targets reply #2; **Ctrl+Y** copies the last reply with no typing. (#362) - **TUI: `/` picker shows every command; `@` picker gains scroll indicators (`cmd/glue/tui`).** The slash-command popup previously reused the file picker's 8-row scroll window with no indicator, so a diff --git a/README.md b/README.md index d25e4b2..9214bd2 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,12 @@ checkpointed to the session store) / `list` (recent goals with status and age) / `clear` subcommands; `/goal -w ` isolates the run in its own git worktree at `.glue/worktrees/` on branch `goal/`, so the loop never touches your checkout and the result is a reviewable -branch — see [ADR-0016](docs/adr/0016-goal-loop.md)). Anywhere in a prompt, +branch — see [ADR-0016](docs/adr/0016-goal-loop.md)), and **`/copy [N]`** (copy an +assistant reply's **raw markdown** — not the glamour-styled, ANSI-laden form +you see on screen — to the system clipboard via an OSC 52 sequence that works +over SSH with no external binary; defaults to the last reply, `/copy 2` +targets reply #2, and **Ctrl+Y** copies the last reply with no typing). +Anywhere in a prompt, **`@`** inlines that file's contents (`@"path with space"` for spaces, `@@literal` to escape — and the workspace blocklist refuses `.env` / `id_rsa` / etc.). Typing @@ -456,6 +461,7 @@ insert. **Enter** sends; **Ctrl+J** inserts a newline (works on every terminal — Shift+Enter does not). **Esc** cancels the current turn; **Ctrl+C** once cancels (and a second press quits); +**Ctrl+Y** copies the last assistant reply to the clipboard; mouse wheel scrolls the transcript; PgUp/PgDn does too. The TUI dependencies (`charmbracelet/{bubbletea,bubbles,lipgloss,glamour}`) live under diff --git a/cmd/glue/tui/clipboard.go b/cmd/glue/tui/clipboard.go new file mode 100644 index 0000000..a887edd --- /dev/null +++ b/cmd/glue/tui/clipboard.go @@ -0,0 +1,79 @@ +package tui + +import ( + "fmt" + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/aymanbagabas/go-osc52/v2" +) + +// clipboardWriter is where OSC 52 copy sequences go. It points at os.Stderr: +// the same controlling tty bubbletea renders to, but a separate stream so the +// momentary escape doesn't interleave with the alt-screen frame buffer on +// stdout. The terminal interprets OSC 52 regardless of alt-screen state. +// Overridable in tests. +var clipboardWriter io.Writer = os.Stderr + +// copyToClipboard asks the terminal to place s on the system clipboard via an +// OSC 52 sequence. Unlike shelling out to pbcopy/xclip, this works over SSH +// and needs no external binary — the trade-off is that the terminal must +// support OSC 52 (most modern ones do) and may cap very large payloads. +func copyToClipboard(s string) error { + _, err := osc52.New(s).WriteTo(clipboardWriter) + return err +} + +// assistantTexts returns the raw markdown of every assistant reply in the +// transcript, oldest first. The raw text (transcriptItem.Text) is the clean +// source the user wants — not the glamour-rendered form they see on screen. +// Empty replies (tool-only turns) are skipped so /copy N numbering matches +// what the user perceives as a "reply." +func (m *Model) assistantTexts() []string { + var out []string + for i := range m.transcript { + it := &m.transcript[i] + if it.Kind == itemAssistant && strings.TrimSpace(it.Text) != "" { + out = append(out, it.Text) + } + } + return out +} + +// pickCopyTarget resolves a /copy argument against the assistant replies, +// returning the text to copy and a human label. A non-empty errMsg means the +// selection failed and should be shown instead of copying. Pure so the +// numbering/bounds logic is unit-testable without a terminal. +func pickCopyTarget(texts []string, arg string) (text, label, errMsg string) { + if len(texts) == 0 { + return "", "", "nothing to copy yet — no assistant reply in this session." + } + arg = strings.TrimSpace(arg) + if arg == "" { + return texts[len(texts)-1], "last reply", "" + } + n, err := strconvAtoi(arg) + if err != nil || n < 1 || n > len(texts) { + return "", "", fmt.Sprintf("/copy: pick a reply between 1 and %d (got %q)", len(texts), arg) + } + return texts[n-1], fmt.Sprintf("reply %d", n), "" +} + +// runSlashCopy handles `/copy [N]` (and Ctrl+Y, which calls it with no arg): +// copies the raw markdown of the most recent assistant reply, or the Nth +// reply (1-based) when N is given, to the system clipboard. +func (m *Model) runSlashCopy(arg string) (tea.Model, tea.Cmd) { + text, label, errMsg := pickCopyTarget(m.assistantTexts(), arg) + if errMsg != "" { + m.appendSystem(errMsg) + } else if err := copyToClipboard(text); err != nil { + m.appendSystem("copy failed: " + err.Error()) + } else { + m.appendSystem(fmt.Sprintf("copied %s (%d chars) to clipboard.", label, len([]rune(text)))) + } + m.rerender() + return m, nil +} diff --git a/cmd/glue/tui/clipboard_test.go b/cmd/glue/tui/clipboard_test.go new file mode 100644 index 0000000..d1bc347 --- /dev/null +++ b/cmd/glue/tui/clipboard_test.go @@ -0,0 +1,118 @@ +package tui + +import ( + "bytes" + "strings" + "testing" +) + +func TestAssistantTextsSkipsEmptyAndNonAssistant(t *testing.T) { + t.Parallel() + m := &Model{transcript: []transcriptItem{ + {Kind: itemUser, Text: "hi"}, + {Kind: itemAssistant, Text: "first reply"}, + {Kind: itemTool, ToolName: "read_file"}, + {Kind: itemAssistant, Text: " "}, // tool-only turn: blank text, skipped + {Kind: itemAssistant, Text: "second"}, // raw markdown, not the rendered form + {Kind: itemSystem, Text: "· note"}, + }} + got := m.assistantTexts() + want := []string{"first reply", "second"} + if len(got) != len(want) { + t.Fatalf("got %d texts %q, want %d", len(got), got, len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("text[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestPickCopyTarget(t *testing.T) { + t.Parallel() + texts := []string{"alpha", "beta", "gamma"} + cases := []struct { + name string + texts []string + arg string + wantText string + wantLabel string + wantErr bool // expect a non-empty errMsg + }{ + {"no replies", nil, "", "", "", true}, + {"default is last", texts, "", "gamma", "last reply", false}, + {"explicit first", texts, "1", "alpha", "reply 1", false}, + {"explicit middle, trimmed", texts, " 2 ", "beta", "reply 2", false}, + {"zero out of range", texts, "0", "", "", true}, + {"too high", texts, "4", "", "", true}, + {"non-numeric", texts, "abc", "", "", true}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + text, label, errMsg := pickCopyTarget(c.texts, c.arg) + if c.wantErr { + if errMsg == "" { + t.Fatalf("want errMsg, got none (text=%q)", text) + } + return + } + if errMsg != "" { + t.Fatalf("unexpected errMsg: %q", errMsg) + } + if text != c.wantText || label != c.wantLabel { + t.Fatalf("got (text=%q,label=%q), want (text=%q,label=%q)", text, label, c.wantText, c.wantLabel) + } + }) + } +} + +func TestCopyToClipboardEmitsOSC52(t *testing.T) { + var buf bytes.Buffer + prev := clipboardWriter + clipboardWriter = &buf + defer func() { clipboardWriter = prev }() + + if err := copyToClipboard("hello # heading"); err != nil { + t.Fatalf("copyToClipboard: %v", err) + } + out := buf.String() + // OSC 52 clipboard set: ESC ] 52 ; c ; BEL/ST. We assert the + // envelope and the base64 of the payload, not the exact terminator. + if !strings.Contains(out, "\x1b]52;c;") { + t.Fatalf("missing OSC 52 envelope in %q", out) + } + // base64("hello # heading") = aGVsbG8gIyBoZWFkaW5n + if !strings.Contains(out, "aGVsbG8gIyBoZWFkaW5n") { + t.Fatalf("payload not base64-encoded in clipboard sequence: %q", out) + } +} + +func TestRunSlashCopyAppendsConfirmation(t *testing.T) { + var buf bytes.Buffer + prev := clipboardWriter + clipboardWriter = &buf + defer func() { clipboardWriter = prev }() + + m := &Model{transcript: []transcriptItem{ + {Kind: itemAssistant, Text: "the answer"}, + }} + m.runSlashCopy("") + + last := m.transcript[len(m.transcript)-1] + if last.Kind != itemSystem || !strings.Contains(last.Text, "copied last reply") { + t.Fatalf("expected confirmation system line, got %#v", last) + } + if !strings.Contains(last.Text, "10 chars") { // len("the answer") == 10 + t.Fatalf("expected char count in confirmation, got %q", last.Text) + } +} + +func TestRunSlashCopyNoReplies(t *testing.T) { + m := &Model{} + m.runSlashCopy("") + last := m.transcript[len(m.transcript)-1] + if last.Kind != itemSystem || !strings.Contains(last.Text, "nothing to copy") { + t.Fatalf("expected 'nothing to copy' line, got %#v", last) + } +} diff --git a/cmd/glue/tui/commands.go b/cmd/glue/tui/commands.go index d9b6d13..14b5781 100644 --- a/cmd/glue/tui/commands.go +++ b/cmd/glue/tui/commands.go @@ -61,6 +61,7 @@ func slashSpecs() []slashSpec { {Name: "usage", Desc: "show this turn's token usage (when the provider reports it)"}, {Name: "tools", Desc: "list registered tools"}, {Name: "goal", Args: "", Desc: "pursue a goal autonomously (also: status · pause · resume · list · clear)"}, + {Name: "copy", Args: "[N]", Desc: "copy an assistant reply's raw markdown to the clipboard (Ctrl+Y = last)"}, {Name: "model", Args: "", Desc: "switch model for subsequent turns"}, {Name: "session", Args: "[id]", Desc: "print current session id, or switch to "}, {Name: "compact", Desc: "summarize older messages to free context window"}, diff --git a/cmd/glue/tui/slashpicker_test.go b/cmd/glue/tui/slashpicker_test.go index 2dcdb3b..58e69ff 100644 --- a/cmd/glue/tui/slashpicker_test.go +++ b/cmd/glue/tui/slashpicker_test.go @@ -77,12 +77,12 @@ func TestSlashRefilterPrefixMatch(t *testing.T) { func TestSlashMatchesStayAlphabetical(t *testing.T) { t.Parallel() p := newSlashPicker() - p.refilter("c") // clear, clone, compact + p.refilter("c") // clear, clone, compact, copy var names []string for _, idx := range p.matches { names = append(names, p.specs[idx].Name) } - want := []string{"clear", "clone", "compact"} + want := []string{"clear", "clone", "compact", "copy"} if strings.Join(names, ",") != strings.Join(want, ",") { t.Fatalf("/c matches = %v, want %v (alphabetical, stable)", names, want) } diff --git a/cmd/glue/tui/tui.go b/cmd/glue/tui/tui.go index 7672ea5..a06a7c8 100644 --- a/cmd/glue/tui/tui.go +++ b/cmd/glue/tui/tui.go @@ -255,7 +255,7 @@ func (m *Model) appendWelcome() { welcomeAccent.Render(" › ") + "Run the tests and fix the first failure.", welcomeAccent.Render(" › ") + "Summarize the changes in this branch vs main.", "", - keyHint.Render(" Enter sends · Ctrl+J newline · / for commands · Esc cancels · Ctrl+C exits"), + keyHint.Render(" Enter sends · Ctrl+J newline · / for commands · Ctrl+Y copies last reply · Esc cancels · Ctrl+C exits"), "", keyHint.Render(fmt.Sprintf(" session %s · %s/%s · %s", m.cfg.SessionID, @@ -704,6 +704,13 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.armedQuit = false + // Ctrl+Y copies the most recent assistant reply's raw markdown to the + // system clipboard — the no-arg form of /copy. Handled here (before the + // pickers and the textarea) so it fires from any input state. + if msg.Type == tea.KeyCtrlY { + return m.runSlashCopy("") + } + // /command autocomplete intercepts navigation/complete keys BEFORE // history scroll and Enter-submit. Tab completes the highlighted // command; Enter runs the input as typed (so a fully-typed command @@ -1005,6 +1012,8 @@ func (m *Model) handleSlash(cmd slashCommand) (tea.Model, tea.Cmd) { return m, nil case "goal": return m.handleSlashGoal(cmd.Arg) + case "copy": + return m.runSlashCopy(cmd.Arg) case "compact": return m.runSlashCompact() case "resume": diff --git a/go.mod b/go.mod index 8472114..302457b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/erain/glue go 1.25.0 require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 @@ -18,7 +19,6 @@ require ( cloud.google.com/go/compute/metadata v0.5.0 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect