Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,12 @@ checkpointed to the session store) / `list` (recent goals with status and
age) / `clear` subcommands; `/goal -w <objective>` isolates the run in its
own git worktree at `.glue/worktrees/<goal-id>` on branch `goal/<id>`, 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,
**`@<path>`** inlines that file's
contents (`@"path with space"` for spaces, `@@literal` to escape — and
the workspace blocklist refuses `.env` / `id_rsa` / etc.). Typing
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions cmd/glue/tui/clipboard.go
Original file line number Diff line number Diff line change
@@ -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
}
118 changes: 118 additions & 0 deletions cmd/glue/tui/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -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 ; <base64> 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)
}
}
1 change: 1 addition & 0 deletions cmd/glue/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<objective>", 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: "<id>", Desc: "switch model for subsequent turns"},
{Name: "session", Args: "[id]", Desc: "print current session id, or switch to <id>"},
{Name: "compact", Desc: "summarize older messages to free context window"},
Expand Down
4 changes: 2 additions & 2 deletions cmd/glue/tui/slashpicker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 10 additions & 1 deletion cmd/glue/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading