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
2 changes: 1 addition & 1 deletion cmd/uishot/testdata/new-session-multiline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
│ │
╰────────────────────────────────────────────────────────────────────────────╯
2 changes: 1 addition & 1 deletion cmd/uishot/testdata/new-session-wide-line.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
│ │
╰────────────────────────────────────────────────────────────────────────────╯
65 changes: 65 additions & 0 deletions internal/tui/clipimage.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion internal/tui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions internal/tui/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading