package tui import ( "bytes" "errors" "image" "image/color" "image/png" "os" "path/filepath" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "codey/internal/attach" "codey/internal/llm" ) func testPNG(t *testing.T) []byte { t.Helper() im := image.NewRGBA(image.Rect(0, 0, 1, 1)) im.Set(0, 0, color.RGBA{R: 9, G: 9, B: 9, A: 255}) var b bytes.Buffer if err := png.Encode(&b, im); err != nil { t.Fatal(err) } return b.Bytes() } // pasteModel is a model whose clipboard holds whatever the test says it does, // with no desktop anywhere near it. func pasteModel(t *testing.T, im attach.Image, err error) *model { t.Helper() m := newTestModel(t) m.grabFn = func() (attach.Image, error) { return im, err } // No test reaches for the real clipboard: an empty one is the default, // and a test that wants words on it says so. m.textFn = func() (string, error) { return "", nil } return m } func clipImage(t *testing.T) attach.Image { t.Helper() im, err := attach.New(testPNG(t), "clipboard") if err != nil { t.Fatal(err) } return im } // run drives a command the way the runtime would: call it, feed what comes // back to Update. func run(t *testing.T, m *model, cmd tea.Cmd) { t.Helper() for cmd != nil { msg := cmd() if msg == nil { return } _, cmd = m.Update(msg) } } func pasteKey(text string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(text), Paste: true} } func TestCtrlVPutsAMarkerInThePrompt(t *testing.T) { m := pasteModel(t, clipImage(t), nil) _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) if got := m.input.Value(); !strings.Contains(got, "[image 1]") { t.Errorf("prompt = %q; want it to hold [image 1]", got) } if m.imgs.Len() != 1 { t.Errorf("%d images attached, want 1", m.imgs.Len()) } } // The marker has to keep clear of the words already typed, or it stops // reading as one thing. func TestAMarkerDoesNotRunIntoTheWordBeforeIt(t *testing.T) { m := pasteModel(t, clipImage(t), nil) m.input.SetValue("look at this") _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) if got := m.input.Value(); !strings.Contains(got, "this [image 1]") { t.Errorf("prompt = %q", got) } } func TestTwoPastesAreNumberedInOrder(t *testing.T) { m := pasteModel(t, clipImage(t), nil) for i := 0; i < 2; i++ { _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) } got := m.input.Value() if !strings.Contains(got, "[image 1]") || !strings.Contains(got, "[image 2]") { t.Errorf("prompt = %q; want both markers", got) } } // ctrl-V on a clipboard holding text is the common case. It must not put a // complaint in the transcript every time. func TestCtrlVWithNoImageSaysNothing(t *testing.T) { m := pasteModel(t, attach.Image{}, attach.ErrNoImage) before := len(m.tr.entries) _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) if m.imgs.Len() != 0 { t.Errorf("%d images attached from an empty clipboard", m.imgs.Len()) } if len(m.tr.entries) != before { t.Errorf("the transcript grew by %d lines", len(m.tr.entries)-before) } } // A terminal that eats ctrl-V pastes the clipboard's text form, which for a // screenshot is nothing. That empty paste is the only signal there is, so it // is taken as one. func TestAnEmptyPasteReachesForTheClipboard(t *testing.T) { m := pasteModel(t, clipImage(t), nil) _, cmd := m.Update(pasteKey("")) run(t, m, cmd) if !strings.Contains(m.input.Value(), "[image 1]") { t.Errorf("prompt = %q", m.input.Value()) } } // Dropping a file on the window pastes its path. func TestPastingAPathAttachesTheFile(t *testing.T) { p := filepath.Join(t.TempDir(), "screenshot.png") if err := os.WriteFile(p, testPNG(t), 0o644); err != nil { t.Fatal(err) } m := pasteModel(t, attach.Image{}, attach.ErrNoImage) _, cmd := m.Update(pasteKey(p)) run(t, m, cmd) if m.imgs.Len() != 1 { t.Fatalf("%d images attached, want 1", m.imgs.Len()) } got := m.input.Value() if !strings.Contains(got, "[image 1]") { t.Errorf("prompt = %q", got) } if strings.Contains(got, p) { t.Errorf("the path was left in the prompt as well: %q", got) } // The status line names the file, so it is obvious which one was taken. if !strings.Contains(m.copied, "screenshot.png") { t.Errorf("status = %q", m.copied) } } // Pasted text is text, even when it mentions a file. func TestPastingTextStillTypesIt(t *testing.T) { m := pasteModel(t, attach.Image{}, attach.ErrNoImage) _, cmd := m.Update(pasteKey("please fix internal/tui/model.go")) run(t, m, cmd) if m.imgs.Len() != 0 { t.Errorf("text became %d images", m.imgs.Len()) } if got := m.input.Value(); got != "please fix internal/tui/model.go" { t.Errorf("prompt = %q", got) } } // A dropped file that cannot be used is worth a word — and the text still // belongs in the box, because it is what was pasted. func TestPastingAnUnreadableImageExplainsItself(t *testing.T) { p := filepath.Join(t.TempDir(), "broken.png") if err := os.WriteFile(p, []byte("not really a png"), 0o644); err != nil { t.Fatal(err) } m := pasteModel(t, attach.Image{}, attach.ErrNoImage) _, cmd := m.Update(pasteKey(p)) run(t, m, cmd) if m.imgs.Len() != 0 { t.Fatalf("%d images attached from a broken file", m.imgs.Len()) } if !strings.Contains(m.input.Value(), "broken.png") { t.Errorf("the pasted text was swallowed: %q", m.input.Value()) } if !transcriptHas(m, "broken.png") { t.Error("nothing was said about the file that would not open") } } func transcriptHas(m *model, want string) bool { for _, e := range m.tr.entries { if strings.Contains(e.text, want) { return true } } return false } // ---- /image ------------------------------------------------------------- func TestImageCommandAttachesAPath(t *testing.T) { p := filepath.Join(t.TempDir(), "diagram.png") if err := os.WriteFile(p, testPNG(t), 0o644); err != nil { t.Fatal(err) } m := pasteModel(t, attach.Image{}, attach.ErrNoImage) run(t, m, m.attachPath(p)) if m.imgs.Len() != 1 || !strings.Contains(m.input.Value(), "[image 1]") { t.Errorf("%d images, prompt %q", m.imgs.Len(), m.input.Value()) } } func TestImageCommandOnAMissingFileComplains(t *testing.T) { m := pasteModel(t, attach.Image{}, attach.ErrNoImage) run(t, m, m.attachPath("/tmp/codey-no-such-picture.png")) if m.imgs.Len() != 0 { t.Fatal("a missing file became an image") } if !transcriptHas(m, "no image at") { t.Error("the missing file went unmentioned") } } // Bare /image is ctrl-V said out loud, so unlike the keystroke it reports an // empty clipboard: it was asked for on purpose. func TestBareImageCommandReportsAnEmptyClipboard(t *testing.T) { m := pasteModel(t, attach.Image{}, errors.New("no clipboard helper installed")) run(t, m, m.attachPath("")) if !transcriptHas(m, "clipboard helper") { t.Error("the missing helper went unmentioned") } } // ---- sending ------------------------------------------------------------ // The point of the whole feature: what goes up is a message with the picture // in it, in the place the marker stood. func TestSendingTurnsMarkersIntoImageBlocks(t *testing.T) { m := pasteModel(t, clipImage(t), nil) _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) blocks := m.imgs.Blocks("what is wrong here " + m.input.Value()) var img, text int for _, b := range blocks { switch b.Type { case llm.BlockImage: img++ if b.Source == nil || b.Source.Data == "" { t.Error("an image block went up empty") } case llm.BlockText: text++ } } if img != 1 || text != 1 { t.Fatalf("%d image and %d text blocks; want one of each", img, text) } } // The next prompt starts with an empty set, or its [image 1] would mean the // last prompt's picture. func TestRunTurnEmptiesTheSet(t *testing.T) { m := pasteModel(t, clipImage(t), nil) _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) m.runTurn("have a look [image 1]") // the command itself is never run if m.imgs.Len() != 0 { t.Fatalf("%d images left over for the next prompt", m.imgs.Len()) } } // Why ctrl+v is ours to take: the textarea binds it to a paste command of its // own, but that command answers with a message type the bubbles package keeps // to itself, and this model's Update has no branch that hands unknown // messages back to the textarea. So the key was already doing nothing before // it was intercepted — this test is what says so, and what will notice if a // future Update starts forwarding. func TestTheTextareasOwnPasteNeverArrives(t *testing.T) { m := newTestModel(t) before := m.input.Value() // Straight to the textarea, going around the interception. input, cmd := m.input.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) m.input = input if cmd == nil { t.Skip("the textarea no longer binds ctrl+v") } if msg := cmd(); msg != nil { m.Update(msg) // the runtime's route: back through the model } if m.input.Value() != before { t.Fatalf("the textarea's paste landed after all: %q", m.input.Value()) } } // ctrl+v means paste. With no picture on the clipboard it has to fall back to // the words on it, or taking the key over would have cost something. func TestCtrlVWithoutAnImagePastesTheText(t *testing.T) { m := pasteModel(t, attach.Image{}, attach.ErrNoImage) m.textFn = func() (string, error) { return "some copied words", nil } m.input.SetValue("say ") _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) if got := m.input.Value(); got != "say some copied words" { t.Errorf("prompt = %q", got) } if m.imgs.Len() != 0 { t.Errorf("text became %d images", m.imgs.Len()) } } // A picture wins over the text form of the same clipboard: copying an image // in a browser leaves both, and the picture is the one that was meant. func TestAnImageBeatsTheClipboardsText(t *testing.T) { m := pasteModel(t, clipImage(t), nil) m.textFn = func() (string, error) { return "https://example.com/cat.png", nil } _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) run(t, m, cmd) if got := m.input.Value(); !strings.Contains(got, "[image 1]") || strings.Contains(got, "http") { t.Errorf("prompt = %q", got) } } // The empty paste is a terminal that already did its own pasting and came up // with nothing. Reading the clipboard's text again there could only duplicate // what it just inserted, so that path asks for the picture alone. func TestAnEmptyPasteDoesNotAlsoPasteText(t *testing.T) { m := pasteModel(t, attach.Image{}, attach.ErrNoImage) m.textFn = func() (string, error) { t.Error("the clipboard's text was read after an empty paste") return "", nil } _, cmd := m.Update(pasteKey("")) run(t, m, cmd) if got := m.input.Value(); got != "" { t.Errorf("prompt = %q, want it left alone", got) } } // typeRunes delivers text the way a terminal that does not bracket its pastes // delivers it: as ordinary keystrokes, indistinguishable from typing. func typeRunes(t *testing.T, m *model, s string) { t.Helper() for _, r := range s { m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } } // The bug this fixes: on a terminal without bracketed paste the path arrives // as keystrokes, no paste is ever detected, and the prompt used to go up with // a filename in it instead of a picture. func TestAnUnbracketedPastedPathStillBecomesAnImage(t *testing.T) { p := filepath.Join(t.TempDir(), "clip.png") if err := os.WriteFile(p, testPNG(t), 0o644); err != nil { t.Fatal(err) } m := pasteModel(t, attach.Image{}, attach.ErrNoImage) typeRunes(t, m, `"`+p+`"`) if m.imgs.Len() != 0 { t.Fatal("typing attached something before enter was pressed") } m.Update(key("enter")) // runTurn has already spent the set, so the proof is what the transcript // shows: the marker, not the path. var said string for _, e := range m.tr.entries { if e.kind == entryUser { said = e.text } } if !strings.Contains(said, "[image 1]") { t.Errorf("the transcript shows %q, want a marker", said) } if strings.Contains(said, ".png") { t.Errorf("the path was left in the message: %q", said) } }