package acp_test import ( "strings" "testing" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/acp" "rickub.com/turbo-editors/turbo-core/theme" "rickub.com/turbo-editors/turbo-core/ui" ) // trace is six lines of the kind of thing that gets pasted into an agent's // box: a log, longer than the box is tall. const trace = "panic: runtime error: index out of range [3] with length 3\n" + "goroutine 1 [running]:\n" + "main.pick(...)\n" + "\t/src/p/main.go:12\n" + "main.main()\n" + "\t/src/p/main.go:7 +0x1d" func TestAMultiLinePasteStandsInTheBoxAsAToken(t *testing.T) { // The ticket's own example: a paste a few rows tall would make the box // unreadable and push what was typed around it off the screen. view, _ := newView(t, 60, 12) typeInto(view, "here is the trace ") view.Paste(trace) typeInto(view, " what is failing?") want := "here is the trace [Pasted #1 · 6 lines · 151 chars] what is failing?" if got := view.Input(); got != want { t.Errorf("Input() = %q\nwant %q", got, want) } } func TestAShortSingleLinePasteGoesInAsIfTyped(t *testing.T) { // A path, a command, an identifier: what you paste to edit around. The // newline copying a line brings along is dropped, not made a second line. view, _ := newView(t, 60, 12) view.Paste("go build ./...\n") if got := view.Input(); got != "go build ./..." { t.Errorf("Input() = %q, want the line itself", got) } } func TestALongSingleLinePasteIsKeptAsideToo(t *testing.T) { view, _ := newView(t, 60, 12) view.Paste(strings.Repeat("x", 300)) if got := view.Input(); got != "[Pasted #1 · 1 line · 300 chars]" { t.Errorf("Input() = %q, want a token for one long line", got) } } func TestWindowsLineEndingsCountAsLines(t *testing.T) { view, _ := newView(t, 60, 12) view.Paste("one\r\ntwo\r\nthree\r\n") if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { t.Errorf("Input() = %q", got) } } func TestTheAgentReceivesThePastedTextWhereTheTokenStood(t *testing.T) { // This is the point: the token is for the box; the model gets the bytes. view, agent := mentioningView(t, nil) agent.handshake() waitFor(t, "ready", view.Session().Ready) typeInto(view, "here is the trace ") view.Paste(trace) typeInto(view, " what is failing?") view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 1 || blocks[0]["type"] != "text" { t.Fatalf("the prompt is %v, want one text block", blocks) } if got := blocks[0]["text"]; got != "here is the trace "+trace+" what is failing?" { t.Errorf("the agent received %q", got) } // The conversation keeps the token, so the exchange stays readable. if got := textOf(view.Session().Entries(), acp.EntryUser); !strings.Contains(got, "[Pasted #1 · 6 lines") || strings.Contains(got, "goroutine") { t.Errorf("the conversation says %q, want the token and not the trace", got) } } func TestAFileNamedInsideAPasteIsNotTakenForAMention(t *testing.T) { // A paste is sent byte for byte. An "@name" in a pasted log is characters, // not a request to attach the file. view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil }) agent.handshake() waitFor(t, "ready", view.Session().Ready) pasted := "see @internal/scanner.go for details\nand the next line" view.Paste(pasted) view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 1 || blocks[0]["type"] != "text" || blocks[0]["text"] != pasted { t.Errorf("the prompt is %v, want the pasted text alone, verbatim", blocks) } } func TestAMentionTypedBesideATokenStillWorks(t *testing.T) { view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil }) agent.handshake() waitFor(t, "ready", view.Session().Ready) typeInto(view, "compare @internal/scanner.go with ") view.Paste(trace) view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 3 || blocks[1]["type"] != "resource" { t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks) } if blocks[2]["text"] != " with "+trace { t.Errorf("block 2 = %q, want the words and the trace", blocks[2]["text"]) } } func TestBackspaceRemovesAWholeToken(t *testing.T) { // A token missing its last bracket stands for nothing, so the token is // one thing to the editing keys. view, agent := mentioningView(t, nil) agent.handshake() waitFor(t, "ready", view.Session().Ready) typeInto(view, "a ") view.Paste(trace) view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone)) if got := view.Input(); got != "a " { t.Errorf("Input() = %q after Backspace, want the token gone whole", got) } // And its text goes with it: nothing of the trace reaches the agent. typeInto(view, "b") view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 1 || blocks[0]["text"] != "a b" { t.Errorf("the agent received %v, want just %q", blocks, "a b") } } func TestDeleteAtTheStartOfATokenRemovesItWhole(t *testing.T) { view, _ := newView(t, 60, 12) view.Paste(trace) typeInto(view, "!") view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone)) if got := view.Input(); got != "!" { t.Errorf("Input() = %q after Delete at the token's start", got) } } func TestTheArrowKeysStepOverATokenRatherThanInto(t *testing.T) { view, _ := newView(t, 60, 12) view.Paste(trace) typeInto(view, "x") view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // before x view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // over the token typeInto(view, "y") view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) // over it again typeInto(view, "z") if got := view.Input(); got != "y[Pasted #1 · 6 lines · 151 chars]zx" { t.Errorf("Input() = %q", got) } } func TestAMoveByRowThatLandsInsideATokenIsPushedToItsEdge(t *testing.T) { // Only ↑/↓ can land inside one; typing into the middle of a token would // break it into text that stands for nothing. view, _ := newView(t, 40, 12) // rows of 37 runes typeInto(view, strings.Repeat("a", 30)+" ") view.Paste(trace) // the 34-rune token wraps onto the second row view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) // column 1 of the token's row typeInto(view, "!") got := view.Input() if !strings.Contains(got, "![Pasted") && !strings.Contains(got, "chars]!") { t.Errorf("Input() = %q, want the ! at an edge of the token", got) } } func TestATokenIsDrawnInItsOwnColour(t *testing.T) { view, _ := newView(t, 60, 12) typeInto(view, "see ") view.Paste(trace) screen := tcell.NewSimulationScreen("UTF-8") if err := screen.Init(); err != nil { t.Fatal(err) } defer screen.Fini() screen.SetSize(60, 12) th, err := theme.Load(theme.DefaultName, "") if err != nil { t.Fatal(err) } view.Draw(ui.NewPainter(screen), th) screen.Show() cells, w, _ := screen.GetContents() row := 9 // the box's first row word, token := cells[row*w+2].Style, cells[row*w+2+len("see ")].Style if word == token { t.Error("the token is drawn in the same colour as the words around it") } if got := string(cells[row*w+2+len("see ")].Runes); got != "[" { t.Errorf("the token's first cell shows %q, want its bracket", got) } } func TestCtrlVPastesWhatTheEditorsClipboardHolds(t *testing.T) { view, _ := newView(t, 60, 12) view.OnPaste = func() string { return "one\ntwo\nthree" } view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlV, 0, tcell.ModNone)) if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { t.Errorf("Input() = %q after Ctrl-V", got) } } func TestShiftInsertPastesToo(t *testing.T) { view, _ := newView(t, 60, 12) view.OnPaste = func() string { return "short" } view.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) if got := view.Input(); got != "short" { t.Errorf("Input() = %q after Shift-Ins", got) } } func TestPasteNumbersRunOnAcrossPrompts(t *testing.T) { // "#1" in an earlier exchange is not the same thing as "#1" in this one. view, agent := mentioningView(t, nil) agent.handshake() waitFor(t, "ready", view.Session().Ready) view.Paste(trace) view.Send() agent.read() view.Paste(trace) if got := view.Input(); !strings.HasPrefix(got, "[Pasted #2 ") { t.Errorf("the second paste is %q, want #2", got) } } func TestAPasteGoesIntoTheBoxWhicheverPaneHadTheFocus(t *testing.T) { view, _ := newView(t, 60, 12) view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // to the conversation view.Paste("hello") typeInto(view, "!") if got := view.Input(); got != "hello!" { t.Errorf("Input() = %q, want the paste and the keystroke after it in the box", got) } }