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