diff --git a/docs/architecture/terminal-app.md b/docs/architecture/terminal-app.md index 54ba0cb3..46af36f4 100644 --- a/docs/architecture/terminal-app.md +++ b/docs/architecture/terminal-app.md @@ -152,6 +152,20 @@ Approvals and denials call the network-grant REST endpoints directly instead of - Drag across transcript text to select it; releasing the mouse automatically copies the selected plain text to the system clipboard - `astonish chat --resume ` — loads history via `GET /api/studio/sessions/{id}` on open +### Paste handling + +Pastes of up to three lines are inserted into the composer as-is and the composer grows vertically for multi-line typed input (up to four visible rows). Pasting four or more lines replaces the composer value with a compact placeholder such as `[Pasted: 8 lines]`, storing the full content for submit. Explicit paste events (`Ctrl+V`, bracketed paste, clipboard paste bindings) collapse immediately. macOS `Command+V` is usually injected by the terminal as ordinary input rather than a paste event; that path collapses as soon as the composer reaches four lines (without waiting for a later keystroke). Any trailing characters that still arrive in the same short paste stream are merged into the same paste block and the placeholder line count is updated. Explicit multi-line editing with `Shift+Enter` / `Alt+Enter` / `Ctrl+J` is left expanded. Pressing enter expands the placeholder back to the full pasted content for history, transcript, `@file` expansion, and the platform turn. Each `[Pasted: N lines]` token is atomic: left/right (and word-motion) keys jump over the whole token, typing cannot insert inside it, and Backspace / `Ctrl+W` / `Alt+Backspace` / Delete remove the entire token in one step. + +### Image paste + +Pasting an image from the system clipboard (`Ctrl+V` / Super+V, and empty text pastes that only contain image data) inserts an atomic placeholder such as `[image #1]`. Multiple images increment the index (`#2`, `#3`, …). Image tokens share the same atomic navigation/delete behavior as text-paste placeholders. On submit, remaining image tokens are sent to Studio as multimodal `attachments` (base64 PNG/JPEG/GIF) while the user transcript keeps the `[image #N]` markers. + +Clipboard image reads are platform-specific: + +- **macOS**: `osascript` pasteboard (`PNGf`, with TIFF→PNG via `sips`) +- **Linux**: Wayland `wl-paste --type image/*` first, then X11 `xclip -t image/*` +- Other platforms: image clipboard read is unavailable (text paste still works) + ### `@file` mentions Typing `@` plus part of a local relative path opens a fuzzy file picker above the composer. Selecting a file inserts `@path/to/file`. On submit, the terminal app reads each mentioned file from the current working directory and appends a bounded `` section to the message sent to the platform, while the transcript keeps showing the user's original text. Absolute paths, directory mentions, workspace escapes, and oversized files are rejected before the turn is sent. diff --git a/docs/website/docs/cli/chat.md b/docs/website/docs/cli/chat.md index 28743d60..7297f0de 100644 --- a/docs/website/docs/cli/chat.md +++ b/docs/website/docs/cli/chat.md @@ -80,6 +80,14 @@ If the pinned provider's credential is revoked or unavailable, the session still The terminal footer shows the active provider and the resolved concrete model when the platform reports them. It does not show `default` as the model name; while the cascade is still resolving, it shows the provider with `model resolving…` instead. +## Paste behavior + +Pasting up to three lines inserts the text directly into the composer. Pasting four or more lines keeps the composer compact by showing a placeholder like `[Pasted: 8 lines]`. The full pasted content is restored when you press `Enter`, so the conversation history and the agent both receive the complete text. The placeholder is atomic: arrow keys jump over it, you cannot type inside it, and Backspace / `Ctrl+W` / `Alt+Backspace` remove the whole block at once. + +Pasting an image from the clipboard inserts `[image #1]` (then `#2`, `#3`, …). Image placeholders are also atomic tokens. When you press `Enter`, Astonish sends the images to the platform as chat attachments so multimodal models can see them, while the chat history keeps the `[image #N]` markers. + +On Linux, image paste requires `wl-paste` (Wayland) or `xclip` (X11) on `PATH`. On macOS, the system pasteboard is used directly. + ## In-Session Commands While in an active chat session, type `/` to access these commands: diff --git a/pkg/client/api.go b/pkg/client/api.go index 87287201..78fdd877 100644 --- a/pkg/client/api.go +++ b/pkg/client/api.go @@ -196,13 +196,21 @@ func (c *Client) GetEffectiveProviders() (*EffectiveProvidersResponse, error) { return &resp, nil } +// ChatAttachment is a base64 file payload for multimodal chat turns. +type ChatAttachment struct { + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + Data string `json:"data"` // base64-encoded file content +} + // ChatRequest represents a message to send to the chat. type ChatRequest struct { - SessionID string `json:"sessionId,omitempty"` - Message string `json:"message"` - AutoApprove bool `json:"autoApprove,omitempty"` - Debug bool `json:"debug,omitempty"` - SystemContext string `json:"systemContext,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Message string `json:"message"` + AutoApprove bool `json:"autoApprove,omitempty"` + Debug bool `json:"debug,omitempty"` + SystemContext string `json:"systemContext,omitempty"` + Attachments []ChatAttachment `json:"attachments,omitempty"` } // SendChatMessage sends a chat message and returns an SSE stream of events. diff --git a/pkg/launcher/tui_chat.go b/pkg/launcher/tui_chat.go index 68b1536f..2ac13981 100644 --- a/pkg/launcher/tui_chat.go +++ b/pkg/launcher/tui_chat.go @@ -2,9 +2,11 @@ package launcher import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" + "path" "strings" "sync" @@ -384,6 +386,7 @@ func (b *platformBackend) RunTurn(ctx context.Context, message string, opts back AutoApprove: autoApprove, Debug: debug, SystemContext: opts.SystemContext, + Attachments: chatAttachmentsFromBackend(opts.Attachments), } stream, err := c.SendChatMessage(req) @@ -784,6 +787,42 @@ func containsRune(s string, r rune) bool { return false } +func chatAttachmentsFromBackend(atts []backend.Attachment) []client.ChatAttachment { + if len(atts) == 0 { + return nil + } + out := make([]client.ChatAttachment, 0, len(atts)) + for _, a := range atts { + if len(a.Data) == 0 { + continue + } + filename := a.Filename + if filename == "" { + ext := "bin" + switch a.MimeType { + case "image/png": + ext = "png" + case "image/jpeg": + ext = "jpg" + case "image/gif": + ext = "gif" + case "image/webp": + ext = "webp" + } + filename = path.Base("attachment." + ext) + } + out = append(out, client.ChatAttachment{ + Filename: filename, + MimeType: a.MimeType, + Data: base64.StdEncoding.EncodeToString(a.Data), + }) + } + if len(out) == 0 { + return nil + } + return out +} + func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { diff --git a/pkg/tui/app.go b/pkg/tui/app.go index 1b5d6ac2..d07dcad0 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -6,6 +6,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -32,6 +33,19 @@ type selectionPoint struct { col int } +type pastedBlock struct { + placeholder string + content string +} + +// pastedImage is a clipboard/image attachment shown as an atomic [image #N] token. +type pastedImage struct { + placeholder string + mimeType string + data []byte + number int +} + // Config configures the terminal chat app. type Config struct { Backend backend.Backend @@ -107,6 +121,22 @@ type model struct { files fileCompletion // planMode asks the platform agent for plans only, without execution. planMode bool + // Pasted blocks keep the composer compact while preserving submitted content. + pastedBlocks []pastedBlock + // Pasted images from the clipboard, shown as atomic [image #N] tokens. + pastedImages []pastedImage + nextImageNum int + // intentionalMultiline is set when the user presses Shift+Enter / Alt+Enter / + // Ctrl+J. While true, the composer may keep 4+ visible lines. Command+V and + // other terminal-injected multi-line pastes leave this false, so they collapse. + intentionalMultiline bool + // pasteStreamUntil marks a short window after a collapse where trailing + // terminal-injected paste characters are merged into the same paste block. + pasteStreamUntil time.Time + pasteIdleSeq int + // composerWatching keeps a short tick loop alive so Command+V collapse does + // not wait for the next keypress to re-enter Update. + composerWatching bool } func newModel(parent context.Context, cfg Config) model { @@ -130,6 +160,11 @@ func newModel(parent context.Context, cfg Config) model { key.WithKeys("shift+enter", "ctrl+j", "alt+enter", "ctrl+m"), key.WithHelp("shift+enter", "newline"), ) + // Prefer explicit clipboard paste when the terminal reports super/cmd. + ta.KeyMap.Paste = key.NewBinding( + key.WithKeys("ctrl+v", "super+v"), + key.WithHelp("ctrl+v", "paste"), + ) ta.SetHeight(1) th.ApplyTextareaStyles(&ta) ta.Focus() @@ -170,9 +205,11 @@ func newModel(parent context.Context, cfg Config) model { type eventMsg events.Event type turnDoneMsg struct{} type turnErrMsg struct{ err error } +type pasteIdleMsg struct{ seq int } +type composerWatchMsg struct{} func (m model) Init() tea.Cmd { - cmds := []tea.Cmd{textarea.Blink, m.spin.Tick} + cmds := []tea.Cmd{textarea.Blink, m.spin.Tick, tea.EnableBracketedPaste} if m.info.IsResumed && m.info.SessionID != "" { cmds = append(cmds, m.loadInitialHistoryCmd()) } @@ -182,6 +219,13 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + if text, ok := textareaPasteText(msg); ok { + if next, cmd, handled := m.tryPasteImage(); handled { + return next, cmd + } + return m.handlePaste(text) + } + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -195,6 +239,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleMouse(msg) case tea.KeyMsg: + // Explicit paste bindings (Ctrl+V / Super+V) prefer clipboard images. + if isClipboardPasteKey(msg) { + if next, cmd, handled := m.tryPasteImage(); handled { + return next, cmd + } + } + if msg.Type == tea.KeyRunes { + text := normalizePasteText(string(msg.Runes)) + // Real paste events and multi-line rune bursts (common for terminal + // Command+V without bracketed-paste markers) go through handlePaste. + if msg.Paste || strings.Contains(text, "\n") { + if msg.Paste && text == "" { + if next, cmd, handled := m.tryPasteImage(); handled { + return next, cmd + } + } + return m.handlePaste(text) + } + } + // Sessions overlay captures keys first. if m.sessions.open { return m.handleSessionsKey(msg) @@ -274,13 +338,61 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Enter sends; Shift+Enter / Alt+Enter / Ctrl+J insert a newline. // Match on String() so "shift+enter" is not treated as plain send. + // Note: KeyCtrlM and KeyEnter share the same code, so "ctrl+m" is "enter". switch msg.String() { case "enter": return m.submit() - case "shift+enter", "alt+enter", "ctrl+j", "ctrl+m": - return m.insertNewline() + case "shift+enter", "alt+enter", "ctrl+j": + return m.insertNewline(true) + case "left", "ctrl+b": + if m.jumpPastePlaceholder(-1) { + return m, nil + } + case "right", "ctrl+f": + if m.jumpPastePlaceholder(1) { + return m, nil + } + case "alt+left", "alt+b", "ctrl+left": + if m.jumpPastePlaceholder(-1) { + return m, nil + } + case "alt+right", "alt+f", "ctrl+right": + if m.jumpPastePlaceholder(1) { + return m, nil + } + case "backspace", "ctrl+h", "ctrl+w", "alt+backspace", "delete", "ctrl+d": + // Treat paste placeholders as a single token for delete operations. + if next, cmd, handled := m.handlePastePlaceholderDelete(msg.String()); handled { + return next, cmd + } } + case pasteIdleMsg: + if msg.seq != m.pasteIdleSeq { + return m, nil + } + // Backup path: collapse any remaining multi-line terminal paste once the + // stream has been idle. Primary collapse usually happens immediately. + if m.forceCollapseInjectedPaste("") { + m.syncSlashCompletion() + m.syncFileCompletion() + } + return m, m.ensureComposerWatch() + + case composerWatchMsg: + // Independent of keypresses: keep collapsing terminal-injected multi-line + // paste as soon as the composer has 4+ content lines. + changed := m.forceCollapseInjectedPaste("") + if changed { + m.syncSlashCompletion() + m.syncFileCompletion() + } + if m.shouldWatchComposer() { + return m, m.composerWatchCmd() + } + m.composerWatching = false + return m, nil + case sessionsLoadedMsg: m.sessions.loading = false if msg.err != nil { @@ -384,18 +496,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Delegate to textarea / viewport when ready. if m.ready { + // Never insert text inside a paste placeholder — treat it as one cell. + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyRunes && !keyMsg.Paste { + m.escapePastePlaceholderForInsert() + } + prevValue := m.ta.Value() prevH := m.composerTextHeight() var cmd tea.Cmd m.ta, cmd = m.ta.Update(msg) cmds = append(cmds, cmd) + // If navigation landed inside a placeholder, snap out. + if keyMsg, ok := msg.(tea.KeyMsg); ok { + m.snapOutOfPastePlaceholder(pasteNavDir(keyMsg.String())) + } + // If typing somehow mutated a placeholder token, restore atomic tokens. + m.repairBrokenPastePlaceholders(prevValue) + if pasteCmd := m.afterComposerChange(prevValue); pasteCmd != nil { + cmds = append(cmds, pasteCmd) + } // Grow/shrink composer when the user adds or removes newlines. if m.composerTextHeight() != prevH { m.layout() m.refreshViewport() } + m.prunePastedBlocks() // Keep completion popups in sync with composer value after typing. m.syncSlashCompletion() m.syncFileCompletion() + if watch := m.ensureComposerWatch(); watch != nil { + cmds = append(cmds, watch) + } } return m, tea.Batch(cmds...) } @@ -543,17 +673,24 @@ func (m model) handleFileCompletionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool } // insertNewline inserts a line break in the composer (Shift+Enter / Ctrl+J). -func (m model) insertNewline() (tea.Model, tea.Cmd) { +// intentional marks user-authored multi-line editing so Command+V paste collapse +// does not rewrite deliberately multi-line prompts. +func (m model) insertNewline(intentional bool) (tea.Model, tea.Cmd) { if m.tr.Streaming && !m.tr.Awaiting { return m, nil } + if intentional { + m.intentionalMultiline = true + } + prevValue := m.ta.Value() prevH := m.composerTextHeight() m.ta.InsertString("\n") + cmd := m.afterComposerChange(prevValue) if m.ready && m.composerTextHeight() != prevH { m.layout() m.refreshViewport() } - return m, nil + return m, cmd } const planModeSystemContext = `You are in Astonish terminal plan mode. @@ -570,11 +707,630 @@ func (m model) turnOptions() backend.TurnOptions { return backend.TurnOptions{} } -func (m model) submit() (tea.Model, tea.Cmd) { - text := strings.TrimSpace(m.ta.Value()) +func isClipboardPasteKey(msg tea.KeyMsg) bool { + switch msg.String() { + case "ctrl+v", "super+v", "cmd+v": + return true + default: + return false + } +} + +func (m model) tryPasteImage() (tea.Model, tea.Cmd, bool) { + if m.sessions.open { + return m, nil, false + } + if m.tr.Streaming && !m.tr.Awaiting { + return m, nil, false + } + data, mime, ok := clipboardImageReader() + if !ok || len(data) == 0 { + return m, nil, false + } + if mime == "" { + if sniffed, ok := sniffImageMIME(data); ok { + mime = sniffed + } else { + mime = "image/png" + } + } + next, cmd := m.insertPastedImage(data, mime) + return next, cmd, true +} + +func (m model) insertPastedImage(data []byte, mimeType string) (tea.Model, tea.Cmd) { + prevH := m.composerTextHeight() + m.nextImageNum++ + num := m.nextImageNum + placeholder := fmt.Sprintf("[image #%d]", num) + m.ta.InsertString(placeholder) + m.pastedImages = append(m.pastedImages, pastedImage{ + placeholder: placeholder, + mimeType: mimeType, + data: data, + number: num, + }) + if m.ready && m.composerTextHeight() != prevH { + m.layout() + m.refreshViewport() + } + m.syncSlashCompletion() + m.syncFileCompletion() + return m, nil +} + +func (m model) handlePaste(text string) (tea.Model, tea.Cmd) { + if m.sessions.open { + return m, nil + } + if m.tr.Streaming && !m.tr.Awaiting { + return m, nil + } + text = normalizePasteText(text) + // Truly empty paste (no runes) often means an image-only clipboard on macOS. + // Do not treat pure newlines as empty — Command+V line streams use "\n" runes. + if text == "" { + if next, cmd, handled := m.tryPasteImage(); handled { + return next, cmd + } + return m, nil + } + m.intentionalMultiline = false + prevH := m.composerTextHeight() + if pasteCollapseLineCount(text) < 4 { + m.ta.InsertString(text) + } else { + m.insertPastedBlock(text) + } + // Always re-check the full composer value in case paste was appended. + m.forceCollapseInjectedPaste("") + if m.ready && m.composerTextHeight() != prevH { + m.layout() + m.refreshViewport() + } + m.syncSlashCompletion() + m.syncFileCompletion() + return m, m.ensureComposerWatch() +} + +// afterComposerChange reacts to composer mutations that were not explicit paste +// events. Terminal-injected multi-line content (Command+V) collapses as soon as +// it reaches 4 lines. Trailing characters that arrive in the same paste stream +// are merged into the existing paste block. Shift+Enter multi-line editing sets +// intentionalMultiline and is left expanded. +func (m *model) afterComposerChange(previous string) tea.Cmd { + current := m.ta.Value() + if current == previous { + return m.ensureComposerWatch() + } + if m.intentionalMultiline { + return nil + } + + // Mid-stream paste after an early collapse: only merge when additional + // lines arrive (terminal still injecting). Same-line typing after the + // placeholder stays visible as normal composition. + if m.pasteStreamActive() && len(m.pastedBlocks) > 0 && pasteLineCount(current) > 1 { + expanded := m.expandPastedBlocks(current) + prevContentLines := 0 + for _, block := range m.pastedBlocks { + if n := pasteCollapseLineCount(block.content); n > prevContentLines { + prevContentLines = n + } + } + if expanded != current && pasteCollapseLineCount(expanded) > prevContentLines { + prevH := m.composerTextHeight() + m.replaceComposerPaste("", expanded, "") + m.touchPasteStream() + if m.ready && m.composerTextHeight() != prevH { + m.layout() + m.refreshViewport() + } + return m.ensureComposerWatch() + } + } + + // 4+ significant lines without intentional multi-line editing → collapse now. + m.forceCollapseInjectedPaste(previous) + return m.ensureComposerWatch() +} + +func (m *model) pasteStreamActive() bool { + return !m.pasteStreamUntil.IsZero() && time.Now().Before(m.pasteStreamUntil) +} + +func (m *model) touchPasteStream() { + m.pasteStreamUntil = time.Now().Add(200 * time.Millisecond) +} + +func (m *model) shouldWatchComposer() bool { + if m.intentionalMultiline { + return false + } + if m.pasteStreamActive() { + return true + } + return composerShouldCollapseValue(m.ta.Value()) +} + +func (m *model) composerWatchCmd() tea.Cmd { + return tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg { + return composerWatchMsg{} + }) +} + +func (m *model) ensureComposerWatch() tea.Cmd { + if !m.shouldWatchComposer() { + m.composerWatching = false + return nil + } + if m.composerWatching { + return nil + } + m.composerWatching = true + return m.composerWatchCmd() +} + +// forceCollapseInjectedPaste collapses multi-line composer content when the +// user did not author it via Shift+Enter. If previous is provided, only the +// newly inserted span is collapsed so surrounding typed text can remain. +func (m *model) forceCollapseInjectedPaste(previous string) bool { + if m.intentionalMultiline { + return false + } + value := m.ta.Value() + if !composerShouldCollapseValue(value) { + return false + } + prevH := m.composerTextHeight() + if previous != "" && previous != value { + if prefix, inserted, suffix, ok := findInsertedText(previous, value); ok && pasteCollapseLineCount(inserted) >= 4 { + m.replaceComposerPaste(prefix, inserted, suffix) + } else { + m.collapseWholeComposerPaste() + } + } else { + m.collapseWholeComposerPaste() + } + m.touchPasteStream() + if m.ready && m.composerTextHeight() != prevH { + m.layout() + m.refreshViewport() + } + return m.ta.Value() != value +} + +func (m *model) collapseWholeComposerPaste() { + value := m.ta.Value() + if !composerShouldCollapseValue(value) { + return + } + m.replaceComposerPaste("", value, "") +} + +func (m *model) insertPastedBlock(content string) { + placeholder := pastePlaceholder(content) + m.ta.InsertString(placeholder) + m.pastedBlocks = append(m.pastedBlocks, pastedBlock{placeholder: placeholder, content: content}) + m.touchPasteStream() +} + +func (m *model) replaceComposerPaste(prefix, content, suffix string) { + // Replace any prior blocks for this composer value with a single mapping. + m.pastedBlocks = nil + placeholder := pastePlaceholder(content) + m.ta.SetValue(prefix + placeholder + suffix) + m.ta.CursorEnd() + m.pastedBlocks = append(m.pastedBlocks, pastedBlock{placeholder: placeholder, content: content}) + m.touchPasteStream() +} + +func pastePlaceholder(content string) string { + n := pasteCollapseLineCount(content) + if n < 1 { + n = pasteLineCount(content) + } + return fmt.Sprintf("[Pasted: %d lines]", n) +} + +func findInsertedText(previous, current string) (prefix, inserted, suffix string, ok bool) { + if len(current) <= len(previous) { + return "", "", "", false + } + start := 0 + for start < len(previous) && start < len(current) && previous[start] == current[start] { + start++ + } + prevEnd := len(previous) + currentEnd := len(current) + for prevEnd > start && currentEnd > start && previous[prevEnd-1] == current[currentEnd-1] { + prevEnd-- + currentEnd-- + } + if previous[:start]+current[currentEnd:] != previous { + return "", "", "", false + } + return current[:start], current[start:currentEnd], current[currentEnd:], true +} + +type pastePlaceholderSpan struct { + start int // rune offset in Value() + end int // rune offset exclusive + placeholder string + line int // hard line index + lineStart int // rune offset of placeholder start within the hard line + lineEnd int // rune offset exclusive within the hard line +} + +func pasteNavDir(key string) int { + switch key { + case "left", "ctrl+b", "alt+left", "alt+b", "ctrl+left", "up", "ctrl+p", "home", "ctrl+a": + return -1 + case "right", "ctrl+f", "alt+right", "alt+f", "ctrl+right", "down", "ctrl+n", "end", "ctrl+e": + return 1 + default: + return 0 + } +} + +func (m model) pastePlaceholderSpans() []pastePlaceholderSpan { + value := m.ta.Value() + if value == "" { + return nil + } + valueRunes := []rune(value) + used := make([]bool, len(valueRunes)) + var spans []pastePlaceholderSpan + + add := func(placeholder string) { + if placeholder == "" { + return + } + phRunes := []rune(placeholder) + for i := 0; i+len(phRunes) <= len(valueRunes); i++ { + match := true + for j := 0; j < len(phRunes); j++ { + if used[i+j] || valueRunes[i+j] != phRunes[j] { + match = false + break + } + } + if !match { + continue + } + for j := 0; j < len(phRunes); j++ { + used[i+j] = true + } + line, lineStart := runeOffsetToLineCol(value, i) + spans = append(spans, pastePlaceholderSpan{ + start: i, + end: i + len(phRunes), + placeholder: placeholder, + line: line, + lineStart: lineStart, + lineEnd: lineStart + len(phRunes), + }) + return + } + } + + for _, block := range m.pastedBlocks { + add(block.placeholder) + } + for _, img := range m.pastedImages { + add(img.placeholder) + } + return spans +} + +func runeOffsetToLineCol(value string, offset int) (line, col int) { + runes := []rune(value) + if offset < 0 { + offset = 0 + } + if offset > len(runes) { + offset = len(runes) + } + for i := 0; i < offset; i++ { + if runes[i] == '\n' { + line++ + col = 0 + } else { + col++ + } + } + return line, col +} + +func (m model) composerLineCol() (line, col int) { + line = m.ta.Line() + li := m.ta.LineInfo() + col = li.StartColumn + li.ColumnOffset + if col < 0 { + col = 0 + } + return line, col +} + +func (m *model) setComposerCursorRuneOffset(offset int) { + value := m.ta.Value() + runes := []rune(value) + if offset < 0 { + offset = 0 + } + if offset > len(runes) { + offset = len(runes) + } + line, col := runeOffsetToLineCol(value, offset) + // Walk to the target hard line from the top. Soft-wrap can make this imperfect, + // but paste placeholders are single-line ASCII tokens and this is good enough. + for m.ta.Line() > 0 { + m.ta.CursorUp() + } + m.ta.CursorStart() + for m.ta.Line() < line { + prev := m.ta.Line() + m.ta.CursorDown() + if m.ta.Line() == prev { + break + } + } + m.ta.SetCursor(col) +} + +// jumpPastePlaceholder makes left/right treat a paste token as one cell. +func (m *model) jumpPastePlaceholder(dir int) bool { + if dir == 0 || (len(m.pastedBlocks) == 0 && len(m.pastedImages) == 0) { + return false + } + line, col := m.composerLineCol() + for _, span := range m.pastePlaceholderSpans() { + if span.line != line { + continue + } + if dir < 0 { + // At end of token or inside it → jump to start. + if col == span.lineEnd || (col > span.lineStart && col < span.lineEnd) { + m.ta.SetCursor(span.lineStart) + return true + } + } + if dir > 0 { + // At start of token or inside it → jump to end. + if col == span.lineStart || (col > span.lineStart && col < span.lineEnd) { + m.ta.SetCursor(span.lineEnd) + return true + } + } + } + return false +} + +// escapePastePlaceholderForInsert moves the caret out of a paste token before typing. +func (m *model) escapePastePlaceholderForInsert() { + line, col := m.composerLineCol() + for _, span := range m.pastePlaceholderSpans() { + if span.line != line { + continue + } + if col > span.lineStart && col < span.lineEnd { + // Prefer inserting after the token. + m.ta.SetCursor(span.lineEnd) + return + } + } +} + +// snapOutOfPastePlaceholder moves the caret out if it landed inside a paste token. +func (m *model) snapOutOfPastePlaceholder(dir int) { + line, col := m.composerLineCol() + for _, span := range m.pastePlaceholderSpans() { + if span.line != line { + continue + } + if col > span.lineStart && col < span.lineEnd { + if dir < 0 { + m.ta.SetCursor(span.lineStart) + } else { + m.ta.SetCursor(span.lineEnd) + } + return + } + } +} + +func (m model) handlePastePlaceholderDelete(key string) (tea.Model, tea.Cmd, bool) { + if len(m.pastedBlocks) == 0 && len(m.pastedImages) == 0 { + return m, nil, false + } + line, col := m.composerLineCol() + forward := key == "delete" || key == "ctrl+d" + for _, span := range m.pastePlaceholderSpans() { + if span.line != line { + continue + } + // Treat the whole token as one cell: delete if caret is on the token + // (start/inside) or at its trailing edge (end). + onToken := col >= span.lineStart && col <= span.lineEnd + if !onToken { + continue + } + // Avoid stealing backspace when caret is at token start and there is + // text before it that the user intends to delete (except ctrl+w which + // should still remove the token when on its leading edge). + if !forward && col == span.lineStart && key != "ctrl+w" && key != "alt+backspace" { + continue + } + // Avoid stealing forward-delete when caret is past the token end. + if forward && col == span.lineEnd { + continue + } + return m.removePastePlaceholderSpan(span) + } + return m, nil, false +} + +func (m model) removePastePlaceholderSpan(span pastePlaceholderSpan) (tea.Model, tea.Cmd, bool) { + value := m.ta.Value() + runes := []rune(value) + if span.start < 0 || span.end > len(runes) || span.start >= span.end { + return m, nil, false + } + prevH := m.composerTextHeight() + newRunes := append(append([]rune{}, runes[:span.start]...), runes[span.end:]...) + m.ta.SetValue(string(newRunes)) + m.setComposerCursorRuneOffset(span.start) + m.removeLastPastedBlock(span.placeholder) + if m.ready && m.composerTextHeight() != prevH { + m.layout() + m.refreshViewport() + } + m.syncSlashCompletion() + m.syncFileCompletion() + return m, nil, true +} + +// repairBrokenPastePlaceholders restores paste tokens if typing split one. +func (m *model) repairBrokenPastePlaceholders(previous string) { + if len(m.pastedBlocks) == 0 && len(m.pastedImages) == 0 { + return + } + current := m.ta.Value() + if current == previous { + return + } + check := func(placeholder string) bool { + if placeholder == "" || strings.Contains(current, placeholder) { + return false + } + if !strings.Contains(previous, placeholder) { + return false + } + // Placeholder text was modified. Revert to previous atomic value and + // place the caret after the token so typing continues outside it. + m.ta.SetValue(previous) + if idx := strings.Index(previous, placeholder); idx >= 0 { + m.setComposerCursorRuneOffset(utf8.RuneCountInString(previous[:idx]) + utf8.RuneCountInString(placeholder)) + } + return true + } + for _, block := range m.pastedBlocks { + if check(block.placeholder) { + return + } + } + for _, img := range m.pastedImages { + if check(img.placeholder) { + return + } + } +} + +func (m *model) removeLastPastedBlock(placeholder string) { + for i := len(m.pastedBlocks) - 1; i >= 0; i-- { + if m.pastedBlocks[i].placeholder == placeholder { + m.pastedBlocks = append(m.pastedBlocks[:i], m.pastedBlocks[i+1:]...) + return + } + } + for i := len(m.pastedImages) - 1; i >= 0; i-- { + if m.pastedImages[i].placeholder == placeholder { + m.pastedImages = append(m.pastedImages[:i], m.pastedImages[i+1:]...) + return + } + } +} + +func (m *model) prunePastedBlocks() { + value := m.ta.Value() + if len(m.pastedBlocks) > 0 { + kept := m.pastedBlocks[:0] + for _, block := range m.pastedBlocks { + if strings.Contains(value, block.placeholder) { + kept = append(kept, block) + } + } + m.pastedBlocks = kept + } + if len(m.pastedImages) > 0 { + keptImg := m.pastedImages[:0] + for _, img := range m.pastedImages { + if strings.Contains(value, img.placeholder) { + keptImg = append(keptImg, img) + } + } + m.pastedImages = keptImg + } +} + +func (m model) expandPastedBlocks(text string) string { + for _, block := range m.pastedBlocks { + text = strings.Replace(text, block.placeholder, block.content, 1) + } + return text +} + +func composerShouldCollapseValue(text string) bool { + trimmed := strings.TrimSpace(text) + if strings.HasPrefix(trimmed, "[Pasted: ") && strings.HasSuffix(trimmed, " lines]") && !strings.Contains(trimmed, "\n") { + return false + } + // Ignore a trailing empty line so Command+V does not collapse on the + // newline before the final content line has arrived. + return pasteCollapseLineCount(text) >= 4 +} + +func textareaPasteText(msg tea.Msg) (string, bool) { + if msg == nil { + return "", false + } + typeName := fmt.Sprintf("%T", msg) + if typeName != "textarea.pasteMsg" { + return "", false + } + text := fmt.Sprint(msg) + return text, text != "" +} + +func normalizePasteText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + return text +} + +func pasteLineCount(text string) int { if text == "" { + return 0 + } + return strings.Count(normalizePasteText(text), "\n") + 1 +} + +// pasteCollapseLineCount counts content lines for paste collapse, ignoring a +// single trailing newline so a paste stream is not collapsed before the final +// line body has been inserted. +func pasteCollapseLineCount(text string) int { + if text == "" { + return 0 + } + text = normalizePasteText(text) + text = strings.TrimSuffix(text, "\n") + if text == "" { + return 0 + } + return strings.Count(text, "\n") + 1 +} + +func (m model) submit() (tea.Model, tea.Cmd) { + rawValue := m.ta.Value() + attachments := m.collectSubmitAttachments(rawValue) + text := strings.TrimSpace(m.expandPastedBlocks(rawValue)) + if text == "" && len(attachments) == 0 { return m, nil } + if text == "" && len(attachments) > 0 { + // Image-only turn still needs a visible/user message marker. + text = strings.TrimSpace(rawValue) + } if m.tr.Streaming && !m.tr.Awaiting { return m, nil } @@ -584,12 +1340,17 @@ func (m model) submit() (tea.Model, tea.Cmd) { // Local slash commands (minimal set for PR1). if strings.HasPrefix(text, "/") { + m.pastedBlocks = nil + m.pastedImages = nil return m.handleSlash(text) } m.history = append(m.history, text) m.historyIdx = -1 m.ta.Reset() + m.pastedBlocks = nil + m.pastedImages = nil + m.intentionalMultiline = false if m.ready { m.layout() } @@ -615,7 +1376,9 @@ func (m model) submit() (tea.Model, tea.Cmd) { turnCtx, cancel := context.WithCancel(m.ctx) m.turnCancel = cancel - ch, err := m.backend.RunTurn(turnCtx, message, m.turnOptions()) + opts := m.turnOptions() + opts.Attachments = attachments + ch, err := m.backend.RunTurn(turnCtx, message, opts) if err != nil { cancel() m.turnCancel = nil @@ -627,6 +1390,33 @@ func (m model) submit() (tea.Model, tea.Cmd) { return m, waitEvent(ch) } +func (m model) collectSubmitAttachments(composerValue string) []backend.Attachment { + if len(m.pastedImages) == 0 { + return nil + } + var out []backend.Attachment + for _, img := range m.pastedImages { + if !strings.Contains(composerValue, img.placeholder) { + continue + } + ext := "png" + switch img.mimeType { + case "image/jpeg": + ext = "jpg" + case "image/gif": + ext = "gif" + case "image/webp": + ext = "webp" + } + out = append(out, backend.Attachment{ + Filename: fmt.Sprintf("image-%d.%s", img.number, ext), + MimeType: img.mimeType, + Data: img.data, + }) + } + return out +} + func (m model) handleSlash(text string) (tea.Model, tea.Cmd) { m.ta.Reset() switch { @@ -683,6 +1473,8 @@ Commands: Type / to open command completion (filters as you type). Type @ plus part of a local path to attach file context. +Paste multi-line text (4+ lines) to insert [Pasted: N lines]. +Paste an image from the clipboard to insert [image #N]. ↑↓ / tab Move selection enter Run selected command esc Close completion @@ -690,6 +1482,7 @@ Type @ plus part of a local path to attach file context. Keys: enter Send message shift+enter Newline (also ctrl+j / alt+enter) + ctrl+v Paste text or image from clipboard y / n Approve / deny tool (when prompted) ctrl+o Expand/collapse last tool activity ctrl+l Sessions picker @@ -775,9 +1568,10 @@ func (m model) paintHeight() int { } // composerTextHeight returns the textarea height: 1 by default, up to 4 when -// the user has entered multiple lines. +// the user has entered multiple lines. Uses the real textarea value so typed +// multi-line content expands; collapsed paste placeholders stay one line. func (m model) composerTextHeight() int { - lines := strings.Count(m.ta.Value(), "\n") + 1 + lines := pasteLineCount(m.ta.Value()) if lines < 1 { lines = 1 } diff --git a/pkg/tui/backend/backend.go b/pkg/tui/backend/backend.go index cfd8bd4b..ea05a508 100644 --- a/pkg/tui/backend/backend.go +++ b/pkg/tui/backend/backend.go @@ -49,11 +49,21 @@ type HistoryEntry struct { Result any } +// Attachment is a file/image payload to send with a chat turn. +type Attachment struct { + Filename string + MimeType string + // Data is raw file bytes (not base64). + Data []byte +} + // TurnOptions configures per-turn behavior for the platform request. type TurnOptions struct { // SystemContext is a per-turn hidden instruction sent to Studio chat. It is // not persisted as a visible user message. SystemContext string + // Attachments are optional multimodal file/image payloads for this turn. + Attachments []Attachment } // NetworkGrantBackend is implemented by platform backends that can resolve diff --git a/pkg/tui/clipboard_image.go b/pkg/tui/clipboard_image.go new file mode 100644 index 00000000..dce1981f --- /dev/null +++ b/pkg/tui/clipboard_image.go @@ -0,0 +1,74 @@ +package tui + +import ( + "bytes" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "strings" +) + +// clipboardImageReader reads an image from the system clipboard when supported. +// Tests override this to inject fixtures without touching the real clipboard. +var clipboardImageReader = readClipboardImage + +// readClipboardImage attempts to load image bytes from the OS clipboard. +// Returns false when no image is available or the platform is unsupported. +func readClipboardImage() (data []byte, mimeType string, ok bool) { + return readClipboardImagePlatform() +} + +func sniffImageMIME(data []byte) (string, bool) { + if len(data) == 0 { + return "", false + } + // Prefer stdlib detection for common formats. + _, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err == nil { + switch format { + case "png": + return "image/png", true + case "jpeg": + return "image/jpeg", true + case "gif": + return "image/gif", true + } + } + // Magic-byte fallbacks for partial/odd clipboard payloads. + if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) { + return "image/png", true + } + if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff { + return "image/jpeg", true + } + if len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))) { + return "image/gif", true + } + return "", false +} + +func containsMIMETarget(list []string, want string) bool { + want = strings.ToLower(want) + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == want || strings.HasPrefix(item, want+";") { + return true + } + } + return false +} + +func parseCommandLines(out []byte) []string { + if len(out) == 0 { + return nil + } + var lines []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/pkg/tui/clipboard_image_darwin.go b/pkg/tui/clipboard_image_darwin.go new file mode 100644 index 00000000..14b2e4bd --- /dev/null +++ b/pkg/tui/clipboard_image_darwin.go @@ -0,0 +1,78 @@ +//go:build darwin + +package tui + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// readClipboardImagePlatform reads a PNG (preferred) from the macOS pasteboard +// using osascript. Terminals rarely deliver image paste events to the app, so +// we read the system clipboard when the user triggers paste. +func readClipboardImagePlatform() (data []byte, mimeType string, ok bool) { + f, err := os.CreateTemp("", "astonish-clip-*.png") + if err != nil { + return nil, "", false + } + path := f.Name() + _ = f.Close() + defer os.Remove(path) + + // Prefer PNG; fall back to TIFF then convert via sips if needed. + script := fmt.Sprintf(` +try + set pngData to (the clipboard as «class PNGf») + set outFile to open for access POSIX file %q with write permission + set eof outFile to 0 + write pngData to outFile + close access outFile + return "png" +on error + try + set tiffData to (the clipboard as «class TIFF») + set tiffPath to %q & ".tiff" + set outFile to open for access POSIX file tiffPath with write permission + set eof outFile to 0 + write tiffData to outFile + close access outFile + return "tiff:" & tiffPath + on error + return "empty" + end try +end try +`, path, path) + + out, err := exec.Command("osascript", "-e", script).CombinedOutput() + if err != nil { + return nil, "", false + } + result := strings.TrimSpace(string(out)) + switch { + case result == "png": + data, err = os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil, "", false + } + if mime, ok := sniffImageMIME(data); ok { + return data, mime, true + } + return data, "image/png", true + case strings.HasPrefix(result, "tiff:"): + tiffPath := strings.TrimPrefix(result, "tiff:") + defer os.Remove(tiffPath) + // Convert TIFF → PNG with sips (always available on macOS). + if err := exec.Command("sips", "-s", "format", "png", tiffPath, "--out", path).Run(); err != nil { + return nil, "", false + } + data, err = os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil, "", false + } + return data, "image/png", true + default: + return nil, "", false + } +} diff --git a/pkg/tui/clipboard_image_linux.go b/pkg/tui/clipboard_image_linux.go new file mode 100644 index 00000000..660710c6 --- /dev/null +++ b/pkg/tui/clipboard_image_linux.go @@ -0,0 +1,83 @@ +//go:build linux + +package tui + +import ( + "os/exec" +) + +// readClipboardImagePlatform reads an image from the Linux clipboard. +// Preference order: +// 1. Wayland: wl-paste (image/png, then image/jpeg, image/gif) +// 2. X11: xclip -t image/png (then jpeg/gif) +// +// Returns false when no image tool is available or the clipboard has no image. +func readClipboardImagePlatform() (data []byte, mimeType string, ok bool) { + if data, mime, ok := readLinuxWaylandImage(); ok { + return data, mime, true + } + if data, mime, ok := readLinuxX11Image(); ok { + return data, mime, true + } + return nil, "", false +} + +func readLinuxWaylandImage() ([]byte, string, bool) { + if _, err := exec.LookPath("wl-paste"); err != nil { + return nil, "", false + } + // Only attempt image types when the compositor advertises them. + targets := listCommandLines("wl-paste", "--list-types") + candidates := []struct { + mime string + args []string + }{ + {"image/png", []string{"--type", "image/png", "--no-newline"}}, + {"image/jpeg", []string{"--type", "image/jpeg", "--no-newline"}}, + {"image/gif", []string{"--type", "image/gif", "--no-newline"}}, + } + for _, c := range candidates { + if len(targets) > 0 && !containsMIMETarget(targets, c.mime) { + continue + } + out, err := exec.Command("wl-paste", c.args...).Output() + if err != nil || len(out) == 0 { + continue + } + if mime, ok := sniffImageMIME(out); ok { + return out, mime, true + } + return out, c.mime, true + } + return nil, "", false +} + +func readLinuxX11Image() ([]byte, string, bool) { + if _, err := exec.LookPath("xclip"); err != nil { + return nil, "", false + } + targets := listCommandLines("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o") + candidates := []string{"image/png", "image/jpeg", "image/gif"} + for _, mime := range candidates { + if len(targets) > 0 && !containsMIMETarget(targets, mime) { + continue + } + out, err := exec.Command("xclip", "-selection", "clipboard", "-t", mime, "-o").Output() + if err != nil || len(out) == 0 { + continue + } + if sniffed, ok := sniffImageMIME(out); ok { + return out, sniffed, true + } + return out, mime, true + } + return nil, "", false +} + +func listCommandLines(name string, args ...string) []string { + out, err := exec.Command(name, args...).Output() + if err != nil { + return nil + } + return parseCommandLines(out) +} diff --git a/pkg/tui/clipboard_image_other.go b/pkg/tui/clipboard_image_other.go new file mode 100644 index 00000000..60c357a1 --- /dev/null +++ b/pkg/tui/clipboard_image_other.go @@ -0,0 +1,8 @@ +//go:build !darwin && !linux + +package tui + +// readClipboardImagePlatform is a no-op on unsupported platforms. +func readClipboardImagePlatform() (data []byte, mimeType string, ok bool) { + return nil, "", false +} diff --git a/pkg/tui/paste_test.go b/pkg/tui/paste_test.go new file mode 100644 index 00000000..81fd60f7 --- /dev/null +++ b/pkg/tui/paste_test.go @@ -0,0 +1,1009 @@ +package tui + +import ( + "context" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/SAP/astonish/pkg/tui/backend" + "github.com/SAP/astonish/pkg/tui/events" +) + +// ── helpers ────────────────────────────────────────────────────────────── + +func newPasteTestModel(t *testing.T) model { + t.Helper() + m := newModel(context.Background(), Config{Backend: staticBackend{}, Width: 100, Height: 30}) + m.ready = true + m.layout() + return m +} + +func newPasteTestModelWithBackend(t *testing.T, b backend.Backend) model { + t.Helper() + m := newModel(context.Background(), Config{Backend: b, Width: 100, Height: 30}) + m.ready = true + m.layout() + return m +} + +func applyKey(t *testing.T, m model, msg tea.Msg) model { + t.Helper() + next, _ := m.Update(msg) + return next.(model) +} + +func pasteFourLines() string { + return "one\ntwo\nthree\nfour" +} + +func seedPlaceholder(t *testing.T, m *model, prefix, suffix, content string) { + t.Helper() + placeholder := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: placeholder, content: content}} + m.ta.SetValue(prefix + placeholder + suffix) + m.ta.CursorEnd() +} + +func assertPlaceholderIntact(t *testing.T, value, placeholder string) { + t.Helper() + if !strings.Contains(value, placeholder) { + t.Fatalf("placeholder %q missing from value %q", placeholder, value) + } + // Ensure the token was not split by an inserted character. + idx := strings.Index(value, "[Pasted:") + if idx < 0 { + return + } + end := strings.Index(value[idx:], "]") + if end < 0 { + t.Fatalf("broken placeholder without closing bracket: %q", value) + } + token := value[idx : idx+end+1] + if !strings.HasPrefix(token, "[Pasted: ") || !strings.HasSuffix(token, " lines]") { + t.Fatalf("malformed placeholder token %q in %q", token, value) + } +} + +// ── pure helpers ───────────────────────────────────────────────────────── + +func TestNormalizePasteText(t *testing.T) { + tests := []struct { + in, want string + }{ + {"a\nb", "a\nb"}, + {"a\rb", "a\nb"}, + {"a\r\nb", "a\nb"}, + {"a\r\nb\rc", "a\nb\nc"}, + {"", ""}, + } + for _, tc := range tests { + if got := normalizePasteText(tc.in); got != tc.want { + t.Fatalf("normalizePasteText(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestPasteLineCountAndCollapseLineCount(t *testing.T) { + tests := []struct { + in string + wantLines int + wantCollapse int + wantShouldColl bool + }{ + {"", 0, 0, false}, + {"one", 1, 1, false}, + {"one\ntwo", 2, 2, false}, + {"one\ntwo\nthree", 3, 3, false}, + {"one\ntwo\nthree\nfour", 4, 4, true}, + // Trailing newline creates an empty 4th visual line but only 3 content lines. + {"one\ntwo\nthree\n", 4, 3, false}, + {"one\ntwo\nthree\nfour\n", 5, 4, true}, + {"one\rtwo\rthree\rfour", 4, 4, true}, + {"[Pasted: 4 lines]", 1, 1, false}, + } + for _, tc := range tests { + if got := pasteLineCount(tc.in); got != tc.wantLines { + t.Fatalf("pasteLineCount(%q) = %d, want %d", tc.in, got, tc.wantLines) + } + if got := pasteCollapseLineCount(tc.in); got != tc.wantCollapse { + t.Fatalf("pasteCollapseLineCount(%q) = %d, want %d", tc.in, got, tc.wantCollapse) + } + if got := composerShouldCollapseValue(tc.in); got != tc.wantShouldColl { + t.Fatalf("composerShouldCollapseValue(%q) = %v, want %v", tc.in, got, tc.wantShouldColl) + } + } +} + +func TestPastePlaceholderFormat(t *testing.T) { + if got := pastePlaceholder("a\nb\nc\nd"); got != "[Pasted: 4 lines]" { + t.Fatalf("pastePlaceholder = %q", got) + } + if got := pastePlaceholder("a\nb\nc\nd\ne\n"); got != "[Pasted: 5 lines]" { + t.Fatalf("pastePlaceholder trailing = %q", got) + } +} + +func TestFindInsertedText(t *testing.T) { + prefix, inserted, suffix, ok := findInsertedText("before after", "before one\ntwo\nthree\nfour after") + if !ok { + t.Fatal("expected inserted text") + } + if prefix != "before " || inserted != "one\ntwo\nthree\nfour " || suffix != "after" { + t.Fatalf("findInsertedText = (%q, %q, %q)", prefix, inserted, suffix) + } +} + +func TestExpandPastedBlocks(t *testing.T) { + m := newPasteTestModel(t) + m.pastedBlocks = []pastedBlock{ + {placeholder: "[Pasted: 4 lines]", content: "one\ntwo\nthree\nfour"}, + } + got := m.expandPastedBlocks("prefix [Pasted: 4 lines] suffix") + want := "prefix one\ntwo\nthree\nfour suffix" + if got != want { + t.Fatalf("expandPastedBlocks = %q, want %q", got, want) + } +} + +// ── paste insertion thresholds ─────────────────────────────────────────── + +func TestSmallPasteInsertsContentAsIs(t *testing.T) { + tests := []struct { + name string + text string + want int // expected composer height + }{ + {"one line", "hello", 1}, + {"two lines", "one\ntwo", 2}, + {"three lines", "one\ntwo\nthree", 3}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newPasteTestModel(t) + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.text), Paste: true}) + if got := m.ta.Value(); got != tc.text { + t.Fatalf("value = %q, want %q", got, tc.text) + } + if len(m.pastedBlocks) != 0 { + t.Fatalf("expected no pastedBlocks, got %+v", m.pastedBlocks) + } + if h := m.composerTextHeight(); h != tc.want { + t.Fatalf("composer height = %d, want %d", h, tc.want) + } + }) + } +} + +func TestLargePasteShowsPlaceholder(t *testing.T) { + tests := []struct { + name string + text string + wantPh string + wantRaw string + }{ + { + name: "bracketed paste 4 lines", + text: pasteFourLines(), + wantPh: "[Pasted: 4 lines]", + wantRaw: pasteFourLines(), + }, + { + name: "unbracketed multi-line runes", + text: "a\nb\nc\nd\ne", + wantPh: "[Pasted: 5 lines]", + wantRaw: "a\nb\nc\nd\ne", + }, + { + name: "carriage returns", + text: "one\rtwo\rthree\rfour", + wantPh: "[Pasted: 4 lines]", + wantRaw: "one\ntwo\nthree\nfour", + }, + { + name: "crlf", + text: "one\r\ntwo\r\nthree\r\nfour", + wantPh: "[Pasted: 4 lines]", + wantRaw: "one\ntwo\nthree\nfour", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newPasteTestModel(t) + // First case uses Paste:true; others exercise unbracketed multi-line KeyRunes. + paste := strings.Contains(tc.name, "bracketed") + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.text), Paste: paste}) + if got := m.ta.Value(); got != tc.wantPh { + t.Fatalf("value = %q, want %q", got, tc.wantPh) + } + if len(m.pastedBlocks) != 1 { + t.Fatalf("pastedBlocks len = %d, want 1: %+v", len(m.pastedBlocks), m.pastedBlocks) + } + if m.pastedBlocks[0].content != tc.wantRaw { + t.Fatalf("stored content = %q, want %q", m.pastedBlocks[0].content, tc.wantRaw) + } + if m.pastedBlocks[0].placeholder != tc.wantPh { + t.Fatalf("stored placeholder = %q, want %q", m.pastedBlocks[0].placeholder, tc.wantPh) + } + if h := m.composerTextHeight(); h != 1 { + t.Fatalf("composer height after large paste = %d, want 1", h) + } + }) + } +} + +func TestUnbracketedLargePasteShowsPlaceholder(t *testing.T) { + m := newPasteTestModel(t) + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(pasteFourLines())}) + if got := m.ta.Value(); got != "[Pasted: 4 lines]" { + t.Fatalf("value = %q", got) + } +} + +// ── Command+V / terminal injection ─────────────────────────────────────── + +func TestCommandVBurstCollapsesAsSoonAsFourLinesArrive(t *testing.T) { + m := newPasteTestModel(t) + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyRunes, Runes: []rune("4")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("5")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("6")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("7")}, + } { + m = applyKey(t, m, msg) + } + if m.intentionalMultiline { + t.Fatal("Command+V path must not mark intentional multi-line") + } + if got := m.ta.Value(); got != "[Pasted: 4 lines]" { + t.Fatalf("value = %q, want immediate placeholder without extra keypress", got) + } + if h := m.composerTextHeight(); h != 1 { + t.Fatalf("composer height = %d, want 1", h) + } + if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].content != "4\n5\n6\n7" { + t.Fatalf("pastedBlocks = %+v", m.pastedBlocks) + } +} + +func TestCommandVDoesNotCollapseBeforeFourthContentLine(t *testing.T) { + m := newPasteTestModel(t) + // Three content lines ending with a trailing newline must stay expanded. + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyRunes, Runes: []rune("1")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("2")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("3")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + } { + m = applyKey(t, m, msg) + } + if strings.Contains(m.ta.Value(), "[Pasted:") { + t.Fatalf("collapsed too early on trailing newline: %q", m.ta.Value()) + } +} + +func TestCommandVTrailingLinesMergeIntoPlaceholder(t *testing.T) { + m := newPasteTestModel(t) + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyRunes, Runes: []rune("1")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("2")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("3")}, + {Type: tea.KeyRunes, Runes: []rune("\n")}, + {Type: tea.KeyRunes, Runes: []rune("4")}, + } { + m = applyKey(t, m, msg) + } + if got := m.ta.Value(); got != "[Pasted: 4 lines]" { + t.Fatalf("initial collapse = %q", got) + } + + m.touchPasteStream() + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("\n")}) + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("5")}) + + if got := m.ta.Value(); got != "[Pasted: 5 lines]" { + t.Fatalf("merged collapse = %q", got) + } + if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].content != "1\n2\n3\n4\n5" { + t.Fatalf("pastedBlocks = %+v", m.pastedBlocks) + } +} + +func TestComposerWatchCollapsesWithoutExtraKeypress(t *testing.T) { + m := newPasteTestModel(t) + m.ta.SetValue("1\n2\n3\n4") + m.intentionalMultiline = false + + m = applyKey(t, m, composerWatchMsg{}) + if got := m.ta.Value(); got != "[Pasted: 4 lines]" { + t.Fatalf("watch collapse = %q", got) + } +} + +func TestTextareaFallbackCollapsesLargeInsertedContent(t *testing.T) { + m := newPasteTestModel(t) + m.ta.InsertString("before ") + previous := m.ta.Value() + m.ta.InsertString(pasteFourLines()) + _ = m.afterComposerChange(previous) + + if got := m.ta.Value(); got != "before [Pasted: 4 lines]" { + t.Fatalf("value = %q", got) + } + if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].content != pasteFourLines() { + t.Fatalf("pastedBlocks = %+v", m.pastedBlocks) + } +} + +// ── intentional multi-line typing ──────────────────────────────────────── + +func TestComposerExpandsForTypedMultiline(t *testing.T) { + m := newPasteTestModel(t) + m.ta.SetValue("line1") + next, _ := m.insertNewline(true) + m = next.(model) + m.ta.InsertString("line2") + + if !m.intentionalMultiline { + t.Fatal("expected intentionalMultiline after Shift+Enter") + } + if h := m.composerTextHeight(); h != 2 { + t.Fatalf("composer height = %d, want 2", h) + } + if got := m.ta.Value(); !strings.Contains(got, "line1") || !strings.Contains(got, "line2") { + t.Fatalf("value = %q", got) + } + if strings.Contains(m.ta.Value(), "[Pasted:") { + t.Fatalf("typed multi-line was collapsed: %q", m.ta.Value()) + } +} + +func TestIntentionalMultilineDoesNotCollapseOnIdleOrWatch(t *testing.T) { + m := newPasteTestModel(t) + m.intentionalMultiline = true + m.ta.SetValue("1\n2\n3\n4") + m.pasteIdleSeq = 1 + + m = applyKey(t, m, pasteIdleMsg{seq: 1}) + if got := m.ta.Value(); got != "1\n2\n3\n4" { + t.Fatalf("idle collapsed intentional multi-line: %q", got) + } + + m = applyKey(t, m, composerWatchMsg{}) + if got := m.ta.Value(); got != "1\n2\n3\n4" { + t.Fatalf("watch collapsed intentional multi-line: %q", got) + } +} + +func TestIntentionalFourLinesStayExpanded(t *testing.T) { + m := newPasteTestModel(t) + m.ta.SetValue("a") + for i := 0; i < 3; i++ { + next, _ := m.insertNewline(true) + m = next.(model) + m.ta.InsertString("x") + } + if !m.intentionalMultiline { + t.Fatal("expected intentional multi-line") + } + if pasteCollapseLineCount(m.ta.Value()) < 4 { + t.Fatalf("expected 4+ lines, got %q", m.ta.Value()) + } + if strings.Contains(m.ta.Value(), "[Pasted:") { + t.Fatalf("intentional multi-line collapsed: %q", m.ta.Value()) + } + if h := m.composerTextHeight(); h != 4 { + t.Fatalf("composer height = %d, want 4", h) + } +} + +// ── submit expands placeholder ─────────────────────────────────────────── + +func TestLargePasteShowsPlaceholderAndSubmitsFullContent(t *testing.T) { + b := &recordingBackend{} + m := newPasteTestModelWithBackend(t, b) + pasted := pasteFourLines() + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(pasted), Paste: true}) + if got := m.ta.Value(); got != "[Pasted: 4 lines]" { + t.Fatalf("value = %q", got) + } + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected submit command") + } + m = next.(model) + + if b.message != pasted { + t.Fatalf("backend message = %q, want %q", b.message, pasted) + } + if len(m.tr.Items) == 0 || m.tr.Items[len(m.tr.Items)-1].Content != pasted { + t.Fatalf("transcript missing full paste: %+v", m.tr.Items) + } + if got := m.ta.Value(); got != "" { + t.Fatalf("textarea after submit = %q", got) + } + if len(m.pastedBlocks) != 0 { + t.Fatalf("pastedBlocks after submit = %+v", m.pastedBlocks) + } + if m.intentionalMultiline { + t.Fatal("intentionalMultiline should clear after submit") + } +} + +func TestSubmitWithPrefixAndPlaceholderExpandsFullMessage(t *testing.T) { + b := &recordingBackend{} + m := newPasteTestModelWithBackend(t, b) + content := pasteFourLines() + seedPlaceholder(t, &m, "note: ", "", content) + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected submit command") + } + m = next.(model) + + want := "note: " + content + if b.message != want { + t.Fatalf("backend message = %q, want %q", b.message, want) + } + if len(m.tr.Items) == 0 || m.tr.Items[len(m.tr.Items)-1].Content != want { + t.Fatalf("transcript = %+v", m.tr.Items) + } +} + +// ── atomic navigation ──────────────────────────────────────────────────── + +func TestPastePlaceholderArrowKeysJumpOverToken(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue("ab" + ph + "cd") + + // Cursor at end of token. + m.ta.SetCursor(len("ab") + len(ph)) + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + _, col := m.composerLineCol() + if col != len("ab") { + t.Fatalf("left from end → col %d, want %d (token start)", col, len("ab")) + } + + // Right from start jumps to end. + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRight}) + _, col = m.composerLineCol() + if col != len("ab")+len(ph) { + t.Fatalf("right from start → col %d, want %d (token end)", col, len("ab")+len(ph)) + } +} + +func TestPastePlaceholderWordMotionJumpsOverToken(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue("ab" + ph + "cd") + m.ta.SetCursor(len("ab") + len(ph)) + + // alt+left / word-back from end of token should jump to start. + if !m.jumpPastePlaceholder(-1) { + t.Fatal("expected jump on word-left at token end") + } + _, col := m.composerLineCol() + if col != len("ab") { + t.Fatalf("word-left col = %d, want %d", col, len("ab")) + } + if !m.jumpPastePlaceholder(1) { + t.Fatal("expected jump on word-right at token start") + } + _, col = m.composerLineCol() + if col != len("ab")+len(ph) { + t.Fatalf("word-right col = %d, want %d", col, len("ab")+len(ph)) + } +} + +func TestPastePlaceholderCannotNavigateInside(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue(ph) + + // Force caret into the middle of the token, then snap out. + m.ta.SetCursor(3) + m.snapOutOfPastePlaceholder(1) + _, col := m.composerLineCol() + if col != len(ph) { + t.Fatalf("snap-out col = %d, want %d (token end)", col, len(ph)) + } + + m.ta.SetCursor(3) + m.snapOutOfPastePlaceholder(-1) + _, col = m.composerLineCol() + if col != 0 { + t.Fatalf("snap-out left col = %d, want 0", col) + } +} + +// ── atomic typing ──────────────────────────────────────────────────────── + +func TestPastePlaceholderTypingDoesNotSplitToken(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue("ab" + ph + "cd") + + // Caret inside token; typing must not mutate the token body. + m.ta.SetCursor(len("ab") + 3) + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("Z")}) + + assertPlaceholderIntact(t, m.ta.Value(), ph) + if strings.Contains(m.ta.Value(), "[PaZsted") { + t.Fatalf("character inserted inside token: %q", m.ta.Value()) + } + // Insert should land outside the token (after it). + if !strings.Contains(m.ta.Value(), ph+"Z") && !strings.Contains(m.ta.Value(), "Z"+ph) { + // repair path may restore previous value; token must still be intact. + assertPlaceholderIntact(t, m.ta.Value(), ph) + } +} + +func TestPastePlaceholderTypingAfterTokenWorks(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue(ph) + m.ta.CursorEnd() + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("!")}) + if got := m.ta.Value(); got != ph+"!" { + t.Fatalf("typing after token = %q, want %q", got, ph+"!") + } + assertPlaceholderIntact(t, m.ta.Value(), ph) +} + +func TestRepairBrokenPastePlaceholders(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + previous := "xx" + ph + "yy" + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + // Simulate a broken mutation of the placeholder text. + m.ta.SetValue("xx[Pasted: BROKEN lines]yy") + m.repairBrokenPastePlaceholders(previous) + if got := m.ta.Value(); got != previous { + t.Fatalf("repair restored %q, want %q", got, previous) + } +} + +// ── atomic deletion ────────────────────────────────────────────────────── + +func TestPastePlaceholderDeleteKeysRemoveWholeToken(t *testing.T) { + content := pasteFourLines() + ph := pastePlaceholder(content) + + tests := []struct { + name string + key tea.KeyType + str string + // setup places caret; default is at end of token with prefix "before " + setup func(m *model) + want string + }{ + { + name: "backspace at end", + key: tea.KeyBackspace, + setup: func(m *model) { + m.ta.SetValue("before " + ph) + m.ta.CursorEnd() + }, + want: "before ", + }, + { + name: "ctrl+w at end", + key: tea.KeyCtrlW, + setup: func(m *model) { + m.ta.SetValue("before " + ph) + m.ta.CursorEnd() + }, + want: "before ", + }, + { + name: "ctrl+w alone", + key: tea.KeyCtrlW, + setup: func(m *model) { + m.ta.SetValue(ph) + m.ta.CursorEnd() + }, + want: "", + }, + { + name: "backspace alone", + key: tea.KeyBackspace, + setup: func(m *model) { + m.ta.SetValue(ph) + m.ta.CursorEnd() + }, + want: "", + }, + { + name: "delete at start", + key: tea.KeyDelete, + setup: func(m *model) { + m.ta.SetValue(ph + " after") + m.ta.SetCursor(0) + }, + want: " after", + }, + { + name: "backspace inside token", + key: tea.KeyBackspace, + setup: func(m *model) { + m.ta.SetValue("x" + ph + "y") + m.ta.SetCursor(len("x") + 3) + }, + want: "xy", + }, + { + name: "ctrl+h at end", + str: "ctrl+h", + setup: func(m *model) { + m.ta.SetValue(ph) + m.ta.CursorEnd() + }, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newPasteTestModel(t) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + tc.setup(&m) + + var msg tea.Msg + if tc.str != "" { + // ctrl+h is KeyCtrlH + msg = tea.KeyMsg{Type: tea.KeyCtrlH} + } else { + msg = tea.KeyMsg{Type: tc.key} + } + m = applyKey(t, m, msg) + + if got := m.ta.Value(); got != tc.want { + t.Fatalf("value = %q, want %q", got, tc.want) + } + if strings.Contains(m.ta.Value(), "Pasted") { + t.Fatalf("placeholder residue remains: %q", m.ta.Value()) + } + if len(m.pastedBlocks) != 0 { + t.Fatalf("pastedBlocks should be cleared, got %+v", m.pastedBlocks) + } + }) + } +} + +func TestPastePlaceholderBackspaceAfterSuffixOnlyDeletesChar(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue(ph + " after") + m.ta.CursorEnd() + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if got := m.ta.Value(); got != ph+" afte" { + t.Fatalf("value = %q, want suffix char deleted only", got) + } + assertPlaceholderIntact(t, m.ta.Value(), ph) + if len(m.pastedBlocks) != 1 { + t.Fatalf("pastedBlocks should remain, got %+v", m.pastedBlocks) + } +} + +func TestCollapsedComposerBackspaceClearsWholePastedValue(t *testing.T) { + m := newPasteTestModel(t) + m.ta.SetValue("4\n5\n6\n7") + m.collapseWholeComposerPaste() + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if got := m.ta.Value(); got != "" { + t.Fatalf("value = %q", got) + } +} + +// ── render / height ────────────────────────────────────────────────────── + +func TestComposerCollapseStoresContentAndRendersPlaceholder(t *testing.T) { + m := newPasteTestModel(t) + m.ta.SetValue("4\n5\n6\n7") + m.collapseWholeComposerPaste() + + if got := m.ta.Value(); got != "[Pasted: 4 lines]" { + t.Fatalf("value = %q", got) + } + if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].content != "4\n5\n6\n7" { + t.Fatalf("pastedBlocks = %+v", m.pastedBlocks) + } + out := stripANSI(m.renderComposer()) + if !strings.Contains(out, "[Pasted: 4 lines]") { + t.Fatalf("composer render missing placeholder:\n%s", out) + } + // Must not still show the raw multi-line body. + if strings.Contains(out, "\n4\n") || strings.Contains(stripANSI(out), "5\n6") { + t.Fatalf("composer still shows raw pasted lines:\n%s", out) + } +} + +func TestComposerHeightUsesRealValueNotCollapsedDisplay(t *testing.T) { + m := newPasteTestModel(t) + m.intentionalMultiline = true + m.ta.SetValue("a\nb\nc") + if h := m.composerTextHeight(); h != 3 { + t.Fatalf("height for 3 intentional lines = %d, want 3", h) + } + m.ta.SetValue("[Pasted: 8 lines]") + if h := m.composerTextHeight(); h != 1 { + t.Fatalf("height for placeholder = %d, want 1", h) + } +} + +// ── prune / lifecycle ──────────────────────────────────────────────────── + +func TestPrunePastedBlocksRemovesMissingPlaceholders(t *testing.T) { + m := newPasteTestModel(t) + m.pastedBlocks = []pastedBlock{ + {placeholder: "[Pasted: 4 lines]", content: "a\nb\nc\nd"}, + {placeholder: "[Pasted: 5 lines]", content: "1\n2\n3\n4\n5"}, + } + m.ta.SetValue("only [Pasted: 4 lines] remains") + m.prunePastedBlocks() + if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].placeholder != "[Pasted: 4 lines]" { + t.Fatalf("prune result = %+v", m.pastedBlocks) + } +} + +func TestPastePlaceholderSpansFindsToken(t *testing.T) { + m := newPasteTestModel(t) + content := pasteFourLines() + ph := pastePlaceholder(content) + m.pastedBlocks = []pastedBlock{{placeholder: ph, content: content}} + m.ta.SetValue("pre " + ph + " post") + + spans := m.pastePlaceholderSpans() + if len(spans) != 1 { + t.Fatalf("spans = %+v", spans) + } + if spans[0].placeholder != ph { + t.Fatalf("span placeholder = %q", spans[0].placeholder) + } + if spans[0].lineStart != len("pre ") { + t.Fatalf("lineStart = %d, want %d", spans[0].lineStart, len("pre ")) + } + if spans[0].lineEnd != len("pre ")+len(ph) { + t.Fatalf("lineEnd = %d, want %d", spans[0].lineEnd, len("pre ")+len(ph)) + } +} + +// ── recording backend ──────────────────────────────────────────────────── + +type recordingBackend struct { + staticBackend + message string + attachments []backend.Attachment +} + +func (b *recordingBackend) RunTurn(_ context.Context, message string, opts backend.TurnOptions) (<-chan events.Event, error) { + b.message = message + b.attachments = append([]backend.Attachment(nil), opts.Attachments...) + ch := make(chan events.Event) + close(ch) + return ch, nil +} + +// ── image paste ────────────────────────────────────────────────────────── + +func TestImagePasteInsertsAtomicPlaceholder(t *testing.T) { + m := newPasteTestModel(t) + png := minimalPNG() + orig := clipboardImageReader + clipboardImageReader = func() ([]byte, string, bool) { + return png, "image/png", true + } + t.Cleanup(func() { clipboardImageReader = orig }) + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlV}) + if got := m.ta.Value(); got != "[image #1]" { + t.Fatalf("value = %q, want [image #1]", got) + } + if len(m.pastedImages) != 1 { + t.Fatalf("pastedImages = %+v", m.pastedImages) + } + if m.pastedImages[0].mimeType != "image/png" || len(m.pastedImages[0].data) == 0 { + t.Fatalf("image payload missing: %+v", m.pastedImages[0]) + } +} + +func TestImagePasteIncrementsIndex(t *testing.T) { + m := newPasteTestModel(t) + png := minimalPNG() + orig := clipboardImageReader + clipboardImageReader = func() ([]byte, string, bool) { + return png, "image/png", true + } + t.Cleanup(func() { clipboardImageReader = orig }) + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlV}) + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlV}) + if got := m.ta.Value(); got != "[image #1][image #2]" { + t.Fatalf("value = %q", got) + } + if len(m.pastedImages) != 2 { + t.Fatalf("pastedImages len = %d", len(m.pastedImages)) + } +} + +func TestImagePasteIsAtomicForNavigationAndDelete(t *testing.T) { + m := newPasteTestModel(t) + png := minimalPNG() + m.pastedImages = []pastedImage{{ + placeholder: "[image #1]", + mimeType: "image/png", + data: png, + number: 1, + }} + m.ta.SetValue("ab[image #1]cd") + m.ta.SetCursor(len("ab") + len("[image #1]")) + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + _, col := m.composerLineCol() + if col != len("ab") { + t.Fatalf("left jump col = %d, want %d", col, len("ab")) + } + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRight}) + _, col = m.composerLineCol() + if col != len("ab")+len("[image #1]") { + t.Fatalf("right jump col = %d", col) + } + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if got := m.ta.Value(); got != "abcd" { + t.Fatalf("backspace value = %q", got) + } + if len(m.pastedImages) != 0 { + t.Fatalf("pastedImages after delete = %+v", m.pastedImages) + } +} + +func TestImagePasteSubmitSendsAttachments(t *testing.T) { + b := &recordingBackend{} + m := newPasteTestModelWithBackend(t, b) + png := minimalPNG() + m.pastedImages = []pastedImage{{ + placeholder: "[image #1]", + mimeType: "image/png", + data: png, + number: 1, + }} + m.ta.SetValue("look [image #1]") + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected submit command") + } + m = next.(model) + + if b.message != "look [image #1]" { + t.Fatalf("message = %q", b.message) + } + if len(b.attachments) != 1 { + t.Fatalf("attachments = %+v", b.attachments) + } + if b.attachments[0].MimeType != "image/png" || len(b.attachments[0].Data) == 0 { + t.Fatalf("attachment payload = %+v", b.attachments[0]) + } + if b.attachments[0].Filename == "" { + t.Fatal("expected attachment filename") + } + if len(m.pastedImages) != 0 { + t.Fatalf("pastedImages should clear after submit") + } +} + +func TestImageOnlySubmitUsesPlaceholderText(t *testing.T) { + b := &recordingBackend{} + m := newPasteTestModelWithBackend(t, b) + png := minimalPNG() + m.pastedImages = []pastedImage{{ + placeholder: "[image #1]", + mimeType: "image/png", + data: png, + number: 1, + }} + m.ta.SetValue("[image #1]") + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected submit command") + } + _ = next + if b.message != "[image #1]" { + t.Fatalf("message = %q", b.message) + } + if len(b.attachments) != 1 { + t.Fatalf("attachments = %+v", b.attachments) + } +} + +func TestEmptyPasteWithImageUsesClipboardImage(t *testing.T) { + m := newPasteTestModel(t) + png := minimalPNG() + orig := clipboardImageReader + clipboardImageReader = func() ([]byte, string, bool) { + return png, "image/png", true + } + t.Cleanup(func() { clipboardImageReader = orig }) + + m = applyKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(""), Paste: true}) + if got := m.ta.Value(); got != "[image #1]" { + t.Fatalf("value = %q", got) + } +} + +func TestSniffImageMIME(t *testing.T) { + png := minimalPNG() + mime, ok := sniffImageMIME(png) + if !ok || mime != "image/png" { + t.Fatalf("sniff png = %q ok=%v", mime, ok) + } + if _, ok := sniffImageMIME([]byte("not-an-image")); ok { + t.Fatal("expected non-image to fail sniff") + } +} + +// minimalPNG is a 1x1 transparent PNG. +func minimalPNG() []byte { + return []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + } +} + +func TestContainsMIMETarget(t *testing.T) { + targets := []string{"text/plain", "image/png", "image/jpeg;charset=utf-8"} + if !containsMIMETarget(targets, "image/png") { + t.Fatal("expected image/png match") + } + if !containsMIMETarget(targets, "image/jpeg") { + t.Fatal("expected image/jpeg prefix match") + } + if containsMIMETarget(targets, "image/gif") { + t.Fatal("did not expect image/gif") + } +} + +func TestParseCommandLines(t *testing.T) { + got := parseCommandLines([]byte("image/png\n\ntext/plain\n")) + if len(got) != 2 || got[0] != "image/png" || got[1] != "text/plain" { + t.Fatalf("parseCommandLines = %#v", got) + } +}