Vectrum
Sign In
561 lines · 15.2 KB · Go
package tui

import (
	"bytes"
	"context"
	"errors"
	"io"
	"strings"
	"sync"
	"testing"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"github.com/muesli/termenv"

	"codey/internal/approval"
	"codey/internal/llm"
)

func press(x, y int) tea.MouseMsg {
	return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}
}

func motion(x, y int) tea.MouseMsg {
	return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionMotion, Button: tea.MouseButtonLeft}
}

func release(x, y int) tea.MouseMsg {
	return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionRelease}
}

func wheel(x, y int, up bool) tea.MouseMsg {
	button := tea.MouseButtonWheelDown
	if up {
		button = tea.MouseButtonWheelUp
	}
	return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: button}
}

// mouseModel is a model whose transcript is known text rather than the
// welcome block, with the clipboard captured instead of written.
func mouseModel(t *testing.T, lines ...string) (*model, *string) {
	t.Helper()
	m := newTestModel(t)
	m.tr.clear()
	for _, line := range lines {
		m.tr.addRaw(line)
	}
	m.sync()

	var clipboard string
	m.copyFn = func(s string) error {
		clipboard = s
		return nil
	}
	return m, &clipboard
}

// rowOf is the inverse of the hit test: which screen row a given row of a
// given piece of the chrome is drawn on. Tests click what they can see, and
// this is how they find it.
func (m *model) rowOf(kind sectionKind, row int) (int, bool) {
	top := m.height - m.chromeRows()
	for _, s := range m.layout() {
		n := lipgloss.Height(s.text)
		if s.kind == kind {
			if row >= n {
				return 0, false
			}
			return top + row, true
		}
		top += n
	}
	return 0, false
}

// The wheel is the whole reason mouse reporting is on: in the alternate
// screen the terminal has no scrollback of its own to roll.
func TestTheWheelScrollsTheTranscript(t *testing.T) {
	m := newTestModel(t)
	for i := 0; i < 200; i++ {
		m.tr.addNotice("line")
	}
	m.sync()
	m.View()

	bottom := m.vp.YOffset
	if bottom == 0 {
		t.Fatal("a 200-entry transcript did not scroll the viewport at all")
	}

	m.Update(wheel(10, 1, true))
	if m.vp.YOffset != bottom-3 {
		t.Errorf("wheel up moved %d lines, want 3", bottom-m.vp.YOffset)
	}

	m.Update(wheel(10, 1, false))
	if m.vp.YOffset != bottom {
		t.Errorf("wheel down did not come back to where it started: %d, want %d", m.vp.YOffset, bottom)
	}
}

func TestDraggingOverTheTranscriptCopiesWhatWasMarked(t *testing.T) {
	m, clipboard := mouseModel(t, "the quick brown fox", "jumps over the lazy dog")

	m.Update(press(4, 0))
	m.Update(motion(8, 0))
	_, cmd := m.Update(release(8, 0))
	drain(t, m, cmd)

	if *clipboard != "quick" {
		t.Errorf("clipboard holds %q, want %q", *clipboard, "quick")
	}
	if !m.sel.on {
		t.Error("the selection vanished when the button came up")
	}
}

func TestDraggingAcrossLinesCopiesBothOfThem(t *testing.T) {
	m, clipboard := mouseModel(t, "the quick brown fox", "jumps over the lazy dog")

	m.Update(press(16, 0))
	m.Update(motion(4, 1))
	_, cmd := m.Update(release(4, 1))
	drain(t, m, cmd)

	if want := "fox\njumps"; *clipboard != want {
		t.Errorf("clipboard holds %q, want %q", *clipboard, want)
	}
}

// A click is how a selection is dismissed, so it must not put a stray
// character on the clipboard on the way.
func TestAClickClearsTheSelectionAndCopiesNothing(t *testing.T) {
	m, clipboard := mouseModel(t, "the quick brown fox")

	m.Update(press(4, 0))
	m.Update(motion(8, 0))
	m.Update(release(8, 0))

	*clipboard = ""
	m.Update(press(2, 0))
	_, cmd := m.Update(release(2, 0))
	drain(t, m, cmd)

	if m.sel.on {
		t.Error("a click left a selection behind")
	}
	if *clipboard != "" {
		t.Errorf("a click copied %q", *clipboard)
	}
}

func TestDoubleClickTakesAWordAndTripleClickTheLine(t *testing.T) {
	m, clipboard := mouseModel(t, "edited internal/tui/model.go:222 for you")

	m.Update(press(12, 0))
	_, cmd := m.Update(press(12, 0))
	drain(t, m, cmd)
	if want := "internal/tui/model.go:222"; *clipboard != want {
		t.Errorf("double-click copied %q, want %q", *clipboard, want)
	}

	_, cmd = m.Update(press(12, 0))
	drain(t, m, cmd)
	if want := "edited internal/tui/model.go:222 for you"; *clipboard != want {
		t.Errorf("triple-click copied %q, want %q", *clipboard, want)
	}
}

// Dragging off the top edge is how you select more than one screenful, so it
// has to scroll rather than stop at the first row.
func TestDraggingPastTheTopScrollsTheTranscript(t *testing.T) {
	m := newTestModel(t)
	for i := 0; i < 200; i++ {
		m.tr.addNotice("line")
	}
	m.sync()
	m.View()

	before := m.vp.YOffset
	m.Update(press(4, 3))
	m.Update(motion(4, 0))
	m.Update(motion(3, 0))
	if m.vp.YOffset >= before {
		t.Errorf("dragging to the top row did not scroll up: offset %d, was %d", m.vp.YOffset, before)
	}
}

// What was copied is reported where the agent's own status goes, not in the
// transcript: copying five things should not leave five lines of scrollback.
func TestCopyingSaysSoWithoutWritingToTheTranscript(t *testing.T) {
	m, _ := mouseModel(t, "the quick brown fox", "jumps over the lazy dog")
	entries := len(m.tr.entries)

	m.Update(press(0, 0))
	m.Update(motion(4, 1))
	_, cmd := m.Update(release(4, 1))
	drain(t, m, cmd)

	if !strings.Contains(m.activity(), "copied 2 lines") {
		t.Errorf("the activity line does not report the copy: %q", m.activity())
	}
	if len(m.tr.entries) != entries {
		t.Errorf("copying added %d transcript entries", len(m.tr.entries)-entries)
	}
}

// The lines a selection names belong to a layout. Anything that lays the
// transcript out again — a resize, or opening up tool output — leaves those
// line numbers pointing at different text, so the selection goes.
func TestRelayingOutTheTranscriptDropsTheSelection(t *testing.T) {
	for _, tc := range []struct {
		name string
		do   func(m *model)
	}{
		{"a resize", func(m *model) { m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) }},
		{"expanding tool output", func(m *model) { m.Update(key("ctrl+r")) }},
		{"typing", func(m *model) { m.Update(key("a")) }},
	} {
		m, _ := mouseModel(t, "the quick brown fox")
		m.Update(press(4, 0))
		m.Update(motion(8, 0))
		m.Update(release(8, 0))
		if !m.sel.on {
			t.Fatalf("%s: no selection to begin with", tc.name)
		}

		tc.do(m)
		if m.sel.on {
			t.Errorf("%s left a selection pointing at the old layout", tc.name)
		}
	}
}

func TestClickingACompletionRowAcceptsIt(t *testing.T) {
	m := newTestModel(t)
	m.input.SetValue("/")
	m.refreshMenu()
	if !m.menuOpen() {
		t.Fatal("the completion menu did not open")
	}
	want := "/" + m.menu.items[2].name
	m.View()

	row, ok := m.rowOf(secMenu, 2)
	if !ok {
		t.Fatal("the completion menu is not on screen anywhere")
	}
	m.Update(press(4, row))

	if got := strings.TrimSpace(m.input.Value()); got != want {
		t.Errorf("clicking the third row put %q in the prompt, want %q", got, want)
	}
}

func TestClickingAModelRowPicksThatModel(t *testing.T) {
	m := newTestModel(t)
	m.openModelPicker()
	m.Update(modelsMsg{models: someModels()})
	if m.phase != phaseModel {
		t.Fatal("the picker is not open")
	}
	m.View()

	// Two rows of heading come before the list.
	row, ok := m.rowOf(secModel, 3)
	if !ok {
		t.Fatal("the model picker is not on screen anywhere")
	}
	m.Update(press(4, row))

	if got := m.opts.Client.Model(); got != "claude-sonnet-5" {
		t.Errorf("clicking the second model chose %q", got)
	}
	if m.phase != phaseIdle {
		t.Error("the picker stayed open after a model was clicked")
	}
}

// The wheel moves through a list the same way the arrow keys do: over an open
// picker it is not the transcript being rolled.
func TestTheWheelMovesThroughAnOpenPicker(t *testing.T) {
	m := newTestModel(t)
	m.openModelPicker()
	m.Update(modelsMsg{models: someModels()})
	m.View()

	row, _ := m.rowOf(secModel, 2)
	m.Update(wheel(4, row, false))
	if m.modelIdx != 1 {
		t.Errorf("wheel down over the picker moved to %d, want 1", m.modelIdx)
	}
	m.Update(wheel(4, row, true))
	if m.modelIdx != 0 {
		t.Errorf("wheel up over the picker moved to %d, want 0", m.modelIdx)
	}
}

func TestClickingTheEffortSliderPicksThatLevel(t *testing.T) {
	m := newTestModel(t)
	m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
	m.openEffort()
	m.View()

	// The labels row, under the level the last slot holds.
	row, ok := m.rowOf(secEffort, 2)
	if !ok {
		t.Fatal("the slider is not on screen anywhere")
	}
	last := len(llm.EffortLevels) - 1
	m.Update(press(effortSlots(119)[last], row))

	if m.opts.Effort != llm.EffortLevels[last] {
		t.Errorf("clicking the last label set effort to %q, want %q", m.opts.Effort, llm.EffortLevels[last])
	}
}

// The slider is four rows, and two of them are words about it: the poles
// above the track and the keys below the labels. Only the track and the
// labels are the slider itself.
func TestClickingTheSliderChromeChangesNothing(t *testing.T) {
	for _, row := range []int{0, 3} {
		m := newTestModel(t)
		m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
		m.openEffort()
		m.View()

		y, ok := m.rowOf(secEffort, row)
		if !ok {
			t.Fatalf("row %d of the slider is not on screen", row)
		}
		m.Update(press(effortSlots(119)[len(llm.EffortLevels)-1], y))

		if m.opts.Effort != "" {
			t.Errorf("clicking row %d of the slider set effort to %q", row, m.opts.Effort)
		}
		if m.phase != phaseEffort {
			t.Errorf("clicking row %d of the slider closed it", row)
		}
	}
}

func TestClickingAnApprovalChoiceAnswersIt(t *testing.T) {
	m := newTestModel(t)
	reply := pend(m, approval.Request{Tool: "bash", Summary: "run ls"})
	m.View()

	opts := m.choices(m.pending[0].req)
	row, ok := m.rowOf(secApproval, approvalChoiceRow(m.approvalView(), len(opts))+len(opts)-1)
	if !ok {
		t.Fatal("the approval box is not on screen anywhere")
	}
	m.Update(press(4, row))

	select {
	case got := <-reply:
		if got != approval.Deny {
			t.Errorf("clicking the last choice decided %v, want deny", got)
		}
	default:
		t.Fatal("clicking a choice decided nothing")
	}
}

func TestClickingTheModeLineCyclesApprovals(t *testing.T) {
	m := newTestModel(t)
	m.View()

	row, ok := m.rowOf(secMode, 0)
	if !ok {
		t.Fatal("the mode line is not on screen anywhere")
	}
	m.Update(press(2, row))

	if !m.opts.Policy.YOLO {
		t.Error("clicking the mode line did not cycle the approval mode")
	}
}

// /mouse hands the mouse back to the terminal, which is the only way to get
// the terminal's own selection back for a copy Codey cannot do.
func TestMouseCanBeHandedBackToTheTerminal(t *testing.T) {
	m, clipboard := mouseModel(t, "the quick brown fox")

	m.submit("/mouse")
	if m.mouseOn {
		t.Fatal("/mouse left mouse reporting on")
	}

	m.Update(press(4, 0))
	m.Update(motion(8, 0))
	_, cmd := m.Update(release(8, 0))
	drain(t, m, cmd)

	if m.sel.on || *clipboard != "" {
		t.Errorf("a drag was still handled with the mouse off: selection=%v clipboard=%q", m.sel.on, *clipboard)
	}

	m.submit("/mouse")
	if !m.mouseOn {
		t.Error("/mouse did not turn reporting back on")
	}
}

// Shift and a click move the far end of what is already selected, which is
// how a selection is corrected without starting it again.
func TestShiftClickExtendsTheSelection(t *testing.T) {
	m, clipboard := mouseModel(t, "the quick brown fox")

	m.Update(press(4, 0))
	m.Update(motion(8, 0))
	m.Update(release(8, 0))

	shift := press(14, 0)
	shift.Shift = true
	m.Update(shift)
	_, cmd := m.Update(release(14, 0))
	drain(t, m, cmd)

	if want := "quick brown"; *clipboard != want {
		t.Errorf("shift+click copied %q, want %q", *clipboard, want)
	}
}

func TestDraggingPastTheBottomScrollsTheTranscript(t *testing.T) {
	m := newTestModel(t)
	for i := 0; i < 200; i++ {
		m.tr.addNotice("line")
	}
	m.sync()
	m.View()
	m.vp.GotoTop()

	before := m.vp.YOffset
	rows := m.height - m.chromeRows()
	m.Update(press(4, rows-4))
	m.Update(motion(4, rows-1))
	m.Update(motion(3, rows-1))
	if m.vp.YOffset <= before {
		t.Errorf("dragging to the bottom row did not scroll down: offset %d, was %d", m.vp.YOffset, before)
	}
}

// A clipboard that would not take the text is worth interrupting the
// transcript for: the text is not where the person is about to paste from.
func TestAFailedCopySaysSoInTheTranscript(t *testing.T) {
	m, _ := mouseModel(t, "the quick brown fox")
	m.copyFn = func(string) error { return errors.New("no clipboard here") }
	entries := len(m.tr.entries)

	m.Update(press(4, 0))
	m.Update(motion(8, 0))
	_, cmd := m.Update(release(8, 0))
	drain(t, m, cmd)

	if len(m.tr.entries) != entries+1 {
		t.Fatalf("a failed copy added %d entries, want 1", len(m.tr.entries)-entries)
	}
	if last := m.tr.entries[len(m.tr.entries)-1]; !strings.Contains(last.text, "no clipboard here") {
		t.Errorf("the failure was not reported: %q", last.text)
	}
}

// lockedBuffer is a writer the renderer's own goroutine and the test can both
// touch. Bubble Tea draws on a ticker, so frames are still landing while the
// test is reading what was drawn.
type lockedBuffer struct {
	mu  sync.Mutex
	buf bytes.Buffer
}

func (b *lockedBuffer) Write(p []byte) (int, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.buf.Write(p)
}

func (b *lockedBuffer) String() string {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.buf.String()
}

// Everything above drives the model with messages the program would have
// made. This one starts the real program and feeds it the bytes a terminal
// actually sends — SGR press, motion, release — because the wire format is
// half of what "the mouse works" means, and nothing else here would catch it
// being asked for wrongly.
func TestARealDragPaintsAndCopies(t *testing.T) {
	forceColour(t)

	m := newModel(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
	})
	m.resize(80, 24)
	m.tr.clear()
	m.tr.addRaw("the quick brown fox")
	m.sync()

	copied := make(chan string, 1)
	m.copyFn = func(s string) error {
		select {
		case copied <- s:
		default:
		}
		return nil
	}

	// SGR mouse reports: button 0 pressed at (5,1), moved (32 is button 0
	// held) to (9,1), released there. Columns and rows are 1-based on the
	// wire. Then ctrl+c, which is what ends the program — after a pause, so
	// that the drag gets a frame drawn for it before the screen goes away.
	in, drag := io.Pipe()
	go func() {
		drag.Write([]byte("\x1b[<0;5;1M" + "\x1b[<32;9;1M" + "\x1b[<0;9;1m"))
		time.Sleep(100 * time.Millisecond)
		drag.Write([]byte("\x03"))
		drag.Close()
	}()

	var out lockedBuffer
	p := tea.NewProgram(m,
		tea.WithInput(in),
		tea.WithOutput(&out),
		tea.WithAltScreen(),
		tea.WithMouseCellMotion(),
		tea.WithoutSignalHandler(),
	)
	if _, err := p.Run(); err != nil {
		t.Fatalf("running the program: %v", err)
	}

	select {
	case got := <-copied:
		if got != "quick" {
			t.Errorf("a real drag copied %q, want %q", got, "quick")
		}
	default:
		t.Error("a real drag copied nothing")
	}

	// The frame the drag produced carries the selection behind the marked
	// text, which is the other half of the wire: the bytes going back out.
	if want := termenv.TrueColor.Color(colSelBg.Dark).Sequence(true); !strings.Contains(out.String(), want) {
		t.Error("no frame painted the selection")
	}

	// The terminal is asked for button-event mouse reporting, and asked to
	// stop on the way out.
	if !strings.Contains(out.String(), "\x1b[?1002h") {
		t.Error("the program never turned mouse reporting on")
	}
	if !strings.Contains(out.String(), "\x1b[?1002l") {
		t.Error("the program left mouse reporting on after it exited")
	}
}