From 552b3d884973b4b82d137c88bcda7154a34d997f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CErick?= Date: Tue, 29 Sep 2026 11:29:11 -0300 Subject: [PATCH] feat(tui): ctrl+v pastes a clipboard image into the first prompt A terminal paste only carries text, so like Claude Code the new-session prompt field now reads the clipboard itself on ctrl+v: an image is saved to a temp PNG and its path inserted at the cursor (agents read an image path as the image). No image falls through to the textarea's text paste. macOS only (osascript); over SSH it reads the host's clipboard. Co-Authored-By: Claude Opus 5.5 --- cmd/uishot/testdata/new-session-multiline.txt | 2 +- cmd/uishot/testdata/new-session-wide-line.txt | 2 +- internal/tui/clipimage.go | 65 +++++++++++++++++++ internal/tui/form.go | 2 +- internal/tui/update.go | 3 + internal/tui/update_test.go | 27 ++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 internal/tui/clipimage.go diff --git a/cmd/uishot/testdata/new-session-multiline.txt b/cmd/uishot/testdata/new-session-multiline.txt index 23e491a..55f6fe0 100644 --- a/cmd/uishot/testdata/new-session-multiline.txt +++ b/cmd/uishot/testdata/new-session-multiline.txt @@ -48,7 +48,7 @@ │ │ │ │ │ optional — the agent's first task; enter for a newline, tab away to │ - │ submit │ + │ submit, ctrl+v pastes an image │ │ tab/↑↓ fields ←→ choose enter esc cancel │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ diff --git a/cmd/uishot/testdata/new-session-wide-line.txt b/cmd/uishot/testdata/new-session-wide-line.txt index 047da17..d9ea901 100644 --- a/cmd/uishot/testdata/new-session-wide-line.txt +++ b/cmd/uishot/testdata/new-session-wide-line.txt @@ -48,7 +48,7 @@ │ │ │ │ │ optional — the agent's first task; enter for a newline, tab away to │ - │ submit │ + │ submit, ctrl+v pastes an image │ │ tab/↑↓ fields ←→ choose enter esc cancel │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/clipimage.go b/internal/tui/clipimage.go new file mode 100644 index 0000000..4397e76 --- /dev/null +++ b/internal/tui/clipimage.go @@ -0,0 +1,65 @@ +package tui + +import ( + "os" + "os/exec" + "runtime" + "strings" +) + +// clipboardImage saves an image on the system clipboard to a temp PNG and +// returns its path, or an error when there is no image (or no way to read +// one). Ctrl+V in the prompt field calls it before falling back to the +// textarea's own text paste — the same trick Claude Code uses, since a +// terminal paste (Cmd+V) only ever carries text. +// +// It reads *this* process's clipboard, so it's front-end work (see the +// client/core split in AGENTS.md), and over SSH it sees the host's +// clipboard, not the viewer's — which in practice means no image, and a +// plain text paste. +// +// A var so tests can stub it. +// +// ponytail: macOS only (osascript); add wl-paste/xclip when someone runs +// the TUI on a Linux desktop. +var clipboardImage = func() (string, error) { + if runtime.GOOS != "darwin" { + return "", os.ErrNotExist + } + f, err := os.CreateTemp("", "moomux-paste-*.png") + if err != nil { + return "", err + } + path := f.Name() + f.Close() + script := `set f to open for access (POSIX file "` + path + `") with write permission +try + write (the clipboard as «class PNGf») to f + close access f +on error e + close access f + error e +end try` + if err := exec.Command("osascript", "-e", script).Run(); err != nil { + os.Remove(path) + return "", err + } + return path, nil +} + +// pasteClipboardImage inserts a clipboard image's path at the prompt's +// cursor, reporting whether there was one. Agents read an image path in a +// prompt as the image. +func (m *Model) pasteClipboardImage() bool { + path, err := clipboardImage() + if err != nil { + return false + } + // Space-padded so the path never fuses with neighbouring text. + sep := " " + if v := m.promptInput.Value(); v == "" || strings.HasSuffix(v, " ") || strings.HasSuffix(v, "\n") { + sep = "" + } + m.promptInput.InsertString(sep + path + " ") + return true +} diff --git a/internal/tui/form.go b/internal/tui/form.go index b8f758f..e257b47 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -191,7 +191,7 @@ var newFormFieldHints = []string{ 1: "list + folder name — blank: from branch, prompt, or random", 2: "resume an existing branch; blank = new branch off base", 3: "only used for a new branch — blank uses the project's base branch", - 4: "optional — the agent's first task; enter for a newline, tab away to submit", + 4: "optional — the agent's first task; enter for a newline, tab away to submit, ctrl+v pastes an image", 5: "optional — shown as a clickable ticket icon next to the session", 6: "optional — shown as a clickable PR icon next to the session", 7: "which agent CLI runs in the session's pane — ←→ to choose", diff --git a/internal/tui/update.go b/internal/tui/update.go index 447d873..e1e571b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1419,6 +1419,9 @@ func (m *Model) updateNewForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case newFormBaseBranchFocus: m.baseBranchInput, cmd = m.baseBranchInput.Update(msg) case newFormPromptFocus: + if msg.String() == "ctrl+v" && m.pasteClipboardImage() { + break + } m.promptInput, cmd = m.promptInput.Update(msg) case newFormTicketFocus: m.ticketInput, cmd = m.ticketInput.Update(msg) diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index fb163e6..63419cf 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1856,3 +1856,30 @@ func TestUpdateFlashSurvivesWhileBrewRuns(t *testing.T) { t.Fatalf("expected busy to clear once the update finishes") } } + +// TestNewSessionPromptCtrlVPastesClipboardImagePath guards the image paste: +// ctrl+v on the prompt with an image on the clipboard inserts the saved +// file's path (agents read that as the image); with no image it must fall +// through to the textarea's normal text paste rather than swallow the key. +func TestNewSessionPromptCtrlVPastesClipboardImagePath(t *testing.T) { + orig := clipboardImage + t.Cleanup(func() { clipboardImage = orig }) + + be := &fakeBackend{} + m := newTestModel(be) + m.Update(keyRune("n")) + tabTo(t, m, newFormPromptFocus) + typeText(m, "look at") + + clipboardImage = func() (string, error) { return "/tmp/moomux-paste-1.png", nil } + m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) + if got := m.promptInput.Value(); got != "look at /tmp/moomux-paste-1.png " { + t.Fatalf("prompt = %q", got) + } + + clipboardImage = func() (string, error) { return "", os.ErrNotExist } + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) + if cmd == nil { + t.Fatal("no image: ctrl+v should fall through to the textarea's text paste") + } +}