package tui
import (
"bytes"
"context"
"errors"
"io"
"strings"
"sync"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"harness/internal/approval"
"harness/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 the harness 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")
}
}