package acp_test import ( "strings" "testing" "time" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/acp" "codeberg.org/turbo-editors/turbo-core/theme" "codeberg.org/turbo-editors/turbo-core/ui" ) // newView returns a view over a session whose agent never answers, sized to a // screen. // // The agent is a pipe nobody reads, so the conversation is whatever the test // puts into it and nothing arrives to race the drawing. The project has been // bitten before by tests that asserted on a screen while a live process wrote // to it, and that hid a real fault for a whole session. func newView(t *testing.T, width, height int) (*acp.View, *fakeAgent) { t.Helper() agent, stream := newFakeAgent(t) session := acp.NewSession(stream, acp.Agent{Name: "Bob"}, t.TempDir(), acp.Options{}) t.Cleanup(func() { _ = session.Close() }) view := acp.NewView(session) view.SetBounds(ui.Rect{X: 0, Y: 0, W: width, H: height}) return view, agent } // draw paints a view onto a simulation screen and returns the rows. func draw(t *testing.T, view *acp.View, width, height int) []string { t.Helper() screen := tcell.NewSimulationScreen("UTF-8") if err := screen.Init(); err != nil { t.Fatalf("starting the screen: %v", err) } defer screen.Fini() screen.SetSize(width, height) th, err := theme.Load(theme.DefaultName, "") if err != nil { t.Fatalf("loading the theme: %v", err) } view.Draw(ui.NewPainter(screen), th) screen.Show() cells, w, h := screen.GetContents() rows := make([]string, h) for y := range h { var row strings.Builder for x := range w { row.WriteString(string(cells[y*w+x].Runes)) } rows[y] = strings.TrimRight(row.String(), " ") } return rows } func TestTheWindowDrawsTheConversationAboveTheBoxYouTypeIn(t *testing.T) { view, _ := newView(t, 60, 12) view.SetInput("what I am typing") rows := draw(t, view, 60, 12) joined := strings.Join(rows, "\n") if !strings.Contains(joined, "what I am typing") { t.Errorf("the input is not on the screen:\n%s", joined) } if !strings.Contains(joined, "starting") { t.Errorf("the rule does not say the agent is starting:\n%s", joined) } // The input must be *below* the rule, which is what makes the two panes // two panes rather than one list. ruleAt, inputAt := -1, -1 for i, row := range rows { if strings.Contains(row, "─") && ruleAt < 0 { ruleAt = i } if strings.Contains(row, "what I am typing") { inputAt = i } } if ruleAt < 0 || inputAt < 0 || inputAt <= ruleAt { t.Errorf("the rule is at %d and the input at %d:\n%s", ruleAt, inputAt, joined) } } func TestTypingGoesIntoTheBoxAndEnterSendsIt(t *testing.T) { view, _ := newView(t, 60, 12) for _, r := range "hello" { view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } if got := view.Input(); got != "hello" { t.Fatalf("Input() = %q", got) } view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) if got := view.Input(); got != "" { t.Errorf("the box still holds %q after Enter", got) } if got := textOf(view.Session().Entries(), acp.EntryUser); got != "hello" { t.Errorf("the conversation says %q", got) } } func TestAltEnterMakesANewLineInsteadOfSending(t *testing.T) { view, _ := newView(t, 60, 12) for _, r := range "one" { view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModAlt)) for _, r := range "two" { view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } if got := view.Input(); got != "one\ntwo" { t.Errorf("Input() = %q, want two lines", got) } if len(view.Session().Entries()) != 0 { t.Error("Alt-Enter sent the prompt") } } func TestEnterOnAnEmptyBoxSendsNothing(t *testing.T) { // Enter on a blank line is somebody thinking, not a turn worth spending. view, _ := newView(t, 60, 12) view.HandleKey(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) if len(view.Session().Entries()) != 0 { t.Errorf("a blank prompt was sent: %v", view.Session().Entries()) } } func TestBackspaceJoinsLinesAtTheStartOfOne(t *testing.T) { view, _ := newView(t, 60, 12) view.SetInput("one\ntwo") // The cursor sits at the end of "two"; three backspaces empty the line and // a fourth joins it to the one above. for range 4 { view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone)) } if got := view.Input(); got != "one" { t.Errorf("Input() = %q, want the lines joined", got) } } func TestTabMovesBetweenTheTwoPanes(t *testing.T) { view, _ := newView(t, 60, 12) view.SetFocused(true) // With the input focused, a letter is typed. After Tab it scrolls instead. view.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone)) if got := view.Input(); got != "a" { t.Fatalf("Input() = %q before Tab", got) } view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'b', tcell.ModNone)) if got := view.Input(); got != "a" { t.Errorf("Input() = %q; typing reached the box although the conversation has the focus", got) } } func TestEscapeIsLeftAloneWhenThereIsNoTurnToStop(t *testing.T) { // Escape is how the rest of the editor closes things. A window that ate it // would be one you could not get out of by habit. view, _ := newView(t, 60, 12) if view.HandleKey(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) { t.Error("Escape was swallowed with nothing running") } } func TestScrollingStopsFollowingAndEndTakesItUpAgain(t *testing.T) { // Reading back through what happened must not be interrupted by the agent // still writing. view, _ := newView(t, 60, 8) view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // focus the conversation for range 30 { view.Session().Prompt("a line of conversation") } draw(t, view, 60, 8) // lay it out, which is when the total is known if !view.Following() { t.Fatal("a fresh window is not following the end") } view.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone)) if view.Following() { t.Error("scrolling up left the window pinned to the end") } view.HandleKey(tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)) if !view.Following() { t.Error("End did not take up following again") } } func TestTheTitleSaysWhatTheAgentIsDoing(t *testing.T) { view, _ := newView(t, 60, 12) if got := view.Title(); !strings.Contains(got, "starting") { t.Errorf("Title() = %q before the handshake", got) } if !strings.Contains(view.Title(), "Bob") { t.Errorf("Title() = %q, want the agent's name in it", view.Title()) } } func TestAWindowTooSmallToDrawDrawsNothingRatherThanPanicking(t *testing.T) { // A terminal can be dragged to any size, and the layout subtracts four // rows for the box and the rule. for _, size := range []struct{ w, h int }{{0, 0}, {1, 1}, {3, 2}, {10, 4}} { view, _ := newView(t, size.w, size.h) view.SetInput("something") draw(t, view, max(size.w, 1), max(size.h, 1)) } } func TestTheSpinnerTurnsWithTheClock(t *testing.T) { // A function of the clock rather than of a counter: nothing has to be // reset when a turn starts, two windows thinking at once turn in step, and // a test can assert on a frame without waiting for one. start := time.Unix(0, 0) first := acp.Spinner(start) if got := acp.Spinner(start.Add(acp.SpinnerPeriod / 3)); got != first { t.Errorf("the frame changed within one period: %c then %c", first, got) } if got := acp.Spinner(start.Add(acp.SpinnerPeriod)); got == first { t.Errorf("the frame did not change after a whole period: %c", got) } // It comes back round rather than running off the end of the frames. seen := map[rune]bool{} for i := range 40 { seen[acp.Spinner(start.Add(time.Duration(i)*acp.SpinnerPeriod))] = true } if len(seen) < 8 { t.Errorf("only %d distinct frames in forty periods", len(seen)) } } func TestTheRuleShowsTheSpinnerWhileTheAgentThinks(t *testing.T) { view, agent := newView(t, 70, 12) agent.handshake() waitFor(t, "ready", view.Session().Ready) at := time.Unix(0, 0) view.SetClock(func() time.Time { return at }) view.Session().Prompt("something") agent.read() waitFor(t, "the turn to start", view.Session().Running) rows := draw(t, view, 70, 12) joined := strings.Join(rows, "\n") if !strings.ContainsRune(joined, acp.Spinner(at)) { t.Errorf("the rule shows no spinner while thinking:\n%s", joined) } if !strings.Contains(joined, "thinking") { t.Errorf("the rule does not say what it is doing:\n%s", joined) } } func TestWithNothingSelectedCopyTakesTheBlockUnderTheCursor(t *testing.T) { // The thing somebody wants is almost always a code block, and selecting it // by hand first is work the editor can do for them. view, _ := newView(t, 70, 20) view.Session().Prompt("show me") transcript := view.Session() _ = transcript feed(t, view, "Here you are:\n```go\npackage main\n\nfunc main() {}\n```\nand that is all.") draw(t, view, 70, 20) // lay it out, which is when the regions exist lines := linesOfView(t, view, 70, 20) at := indexOfLine(lines, "package main") if at < 0 { t.Fatalf("the code never appeared:\n%s", strings.Join(lines, "\n")) } view.SetCaretForTest(at) text, ok := view.Copy() if !ok { t.Fatal("Copy() found nothing to copy") } if !strings.Contains(text, "package main") || !strings.Contains(text, "func main() {}") { t.Errorf("Copy() = %q, want the whole code block", text) } if strings.Contains(text, "Here you are") || strings.Contains(text, "that is all") { t.Errorf("Copy() = %q, want the block alone rather than the prose around it", text) } if strings.HasPrefix(text, " ") { t.Errorf("Copy() = %q, want the drawing indent taken off", text) } } func TestShiftArrowsSelectLinesAndCtrlCCopiesThem(t *testing.T) { view, _ := newView(t, 70, 20) view.SetFocused(true) feed(t, view, "one\ntwo\nthree") draw(t, view, 70, 20) view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // the conversation view.SetCaretForTest(indexOfLine(linesOfView(t, view, 70, 20), "one")) view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift)) from, to, ok := view.Selection() if !ok || to-from != 1 { t.Fatalf("Selection() = %d..%d ok=%v, want two lines", from, to, ok) } var copied string view.OnCopy = func(text string) { copied = text } view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlC, 0, tcell.ModNone)) if !strings.Contains(copied, "one") || !strings.Contains(copied, "two") { t.Errorf("copied %q, want both selected lines", copied) } if strings.Contains(copied, "three") { t.Errorf("copied %q, want only what was selected", copied) } if _, _, still := view.Selection(); still { t.Error("the selection survived being copied") } } func TestEscapeDropsTheSelectionBeforeItStopsATurn(t *testing.T) { // Two meanings on one key, ordered by how local they are. view, _ := newView(t, 70, 20) view.SetFocused(true) feed(t, view, "one\ntwo") draw(t, view, 70, 20) view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift)) if _, _, ok := view.Selection(); !ok { t.Fatal("nothing was selected to begin with") } if !view.HandleKey(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) { t.Error("Escape was not claimed although there was a selection") } if _, _, ok := view.Selection(); ok { t.Error("Escape left the selection in place") } // With neither a selection nor a turn, Escape belongs to the editor again. if view.HandleKey(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) { t.Error("Escape was swallowed with nothing to do") } } func TestASelectedLineIsDrawnInTheSelectionColour(t *testing.T) { view, _ := newView(t, 70, 20) view.SetFocused(true) feed(t, view, "one\ntwo") draw(t, view, 70, 20) view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) view.SetCaretForTest(indexOfLine(linesOfView(t, view, 70, 20), "one")) view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift)) th, err := theme.Load(theme.DefaultName, "") if err != nil { t.Fatalf("loading the theme: %v", err) } wantFg, wantBg, _ := th.Style(theme.KeyEditorSelection).Decompose() screen := tcell.NewSimulationScreen("UTF-8") if err := screen.Init(); err != nil { t.Fatalf("starting the screen: %v", err) } defer screen.Fini() screen.SetSize(70, 20) view.Draw(ui.NewPainter(screen), th) screen.Show() cells, w, _ := screen.GetContents() found := false for y := range 20 { for x := range w { cell := cells[y*w+x] if string(cell.Runes) != "o" { continue } fg, bg, _ := cell.Style.Decompose() if fg == wantFg && bg == wantBg { found = true } } } if !found { t.Error("no cell of the selected line is drawn in the selection colour") } } // feed puts an agent message into a view's conversation without a live agent. func feed(t *testing.T, view *acp.View, text string) { t.Helper() view.Session().AddAgentTextForTest(text) } // linesOfView returns the conversation as it was last laid out. func linesOfView(t *testing.T, view *acp.View, width, height int) []string { t.Helper() var out []string for _, line := range view.LinesForTest() { out = append(out, line.Text) } return out } // indexOfLine returns where a line whose text contains want sits, or -1. func indexOfLine(lines []string, want string) int { for i, line := range lines { if strings.Contains(line, want) { return i } } return -1 } func TestCopyingABlockLeavesTheSpeakersLabelBehind(t *testing.T) { // "‣ Bob (llama.cpp)" above a code block is furniture. Pasting it into a // source file is never what anybody meant, and it was in the first version // of this — found by copying from the real binary and reading the OSC 52 // payload back off the wire. view, _ := newView(t, 70, 20) feed(t, view, "```go\nfunc Reverse(s string) string {\n\treturn s\n}\n```") draw(t, view, 70, 20) lines := linesOfView(t, view, 70, 20) at := indexOfLine(lines, "func Reverse") if at < 0 { t.Fatalf("the code never appeared:\n%s", strings.Join(lines, "\n")) } view.SetCaretForTest(at) text, ok := view.Copy() if !ok { t.Fatal("Copy() found nothing") } if strings.Contains(text, "‣") { t.Errorf("Copy() = %q, want no speaker label in it", text) } if !strings.HasPrefix(text, "func Reverse") { t.Errorf("Copy() = %q, want it to start at the code", text) } } func TestCopyingAToolsOutputLeavesItsHeadingBehind(t *testing.T) { view, agent := newView(t, 70, 20) id := agent.handshake() waitFor(t, "ready", view.Session().Ready) agent.update(id, `{"sessionUpdate":"tool_call","toolCallId":"c1","title":"Shell","status":"completed","rawInput":{"cmd":"ls"},"content":[{"type":"content","content":{"type":"text","text":"one.go\ntwo.go"}}]}`) waitFor(t, "the tool output", func() bool { return strings.Contains(strings.Join(linesOfView(t, view, 70, 20), "\n"), "one.go") }) draw(t, view, 70, 20) view.SetCaretForTest(indexOfLine(linesOfView(t, view, 70, 20), "one.go")) text, ok := view.Copy() if !ok { t.Fatal("Copy() found nothing") } if strings.Contains(text, "Shell") || strings.Contains(text, "✓") { t.Errorf("Copy() = %q, want the output without the heading", text) } if !strings.Contains(text, "one.go") || !strings.Contains(text, "two.go") { t.Errorf("Copy() = %q, want the whole output", text) } }