package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"codey/internal/approval"
"codey/internal/llm"
)
// newTestTabs builds a strip whose NewSession hands back empty sessions: no
// agent and no supervisor, which is all a test of the strip itself needs. The
// counter says how many were asked for.
func newTestTabs(t *testing.T) (*tabs, *int, *int) {
t.Helper()
opened, closed := 0, 0
tb := newTabs(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: "/tmp/repo",
Policy: approval.New(nil),
NewSession: func(root string) (SessionSpec, error) {
opened++
return SessionSpec{
Policy: approval.New(nil),
SessionID: "sess",
Close: func() { closed++ },
}, nil
},
})
tb.attach(&recorder{})
tb.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return tb, &opened, &closed
}
func altKey(r rune) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}, Alt: true}
}
func TestTabsStartAsOneUndecoratedSession(t *testing.T) {
tb, _, _ := newTestTabs(t)
if got := len(tb.list); got != 1 {
t.Fatalf("tabs at startup = %d, want 1", got)
}
// One session is not a strip: nothing is drawn for it, and the
// conversation gets the whole terminal, exactly as it did before tabs.
if rows := tb.barRows(); rows != 0 {
t.Errorf("barRows with one tab = %d, want 0", rows)
}
if h := tb.cur().m.height; h != 24 {
t.Errorf("height with one tab = %d, want the full 24", h)
}
}
func TestCtrlTOpensASessionInANewTab(t *testing.T) {
tb, opened, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
if *opened != 1 {
t.Fatalf("NewSession called %d times, want 1", *opened)
}
if got := len(tb.list); got != 2 {
t.Fatalf("tabs after ctrl+t = %d, want 2", got)
}
if tb.active != 1 {
t.Errorf("active tab = %d, want the new one (1)", tb.active)
}
// Ids are what messages are addressed by, so two tabs must never share one.
if tb.list[0].m.id == tb.list[1].m.id {
t.Errorf("both tabs have id %d", tb.list[0].m.id)
}
// The new conversation is wired to the program, or nothing it says would
// ever arrive.
if tb.list[1].m.bridge.p == nil || tb.list[1].m.bridge.tab != tb.list[1].m.id {
t.Error("the new tab's bridge is not wired to its own id")
}
}
func TestSecondTabCostsTheStripARow(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
if rows := tb.barRows(); rows != 1 {
t.Fatalf("barRows with two tabs = %d, want 1", rows)
}
for i, tab := range tb.list {
if tab.m.height != 23 {
t.Errorf("tab %d height = %d, want 23 — the strip takes a row from every tab, not just the one on screen", i, tab.m.height)
}
}
if got := strings.Count(tb.View(), "\n"); got == 0 {
t.Fatal("the view has no rows at all")
}
if !strings.Contains(tb.View(), "new session") {
t.Error("the strip does not name the tabs")
}
}
func TestTabsWithoutAFactorySaySoAndStayPut(t *testing.T) {
tb := newTabs(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: "/tmp/repo",
Policy: approval.New(nil),
})
tb.attach(&recorder{})
tb.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
if got := len(tb.list); got != 1 {
t.Fatalf("tabs = %d, want the one we started with", got)
}
if !strings.Contains(tb.View(), "cannot open a second session") {
t.Error("nothing was said about why no tab opened")
}
}
func TestTaggedMessagesReachTheirOwnTab(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
first, second := tb.list[0].m, tb.list[1].m
// Tab 2 is on screen; a turn that finishes in tab 1 still belongs to tab 1.
tb.Update(forTab{id: first.id, msg: evText{text: "an answer for the tab you left"}})
if !strings.Contains(first.tr.render(), "an answer for the tab you left") {
t.Error("the message did not land in the tab it was addressed to")
}
if strings.Contains(second.tr.render(), "an answer for the tab you left") {
t.Error("the message landed in the tab that happened to be on screen")
}
if !tb.list[0].unseen {
t.Error("a tab that spoke while you were elsewhere is not marked")
}
// Looking at it is what clears the mark.
tb.Update(altKey('1'))
if tb.list[0].unseen {
t.Error("the mark survived being looked at")
}
}
func TestMessagesForAClosedTabAreDropped(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
gone := tb.list[1].m.id
tb.Update(closeTabMsg{})
// Nothing to assert but that it does not panic or go to the wrong tab:
// an agent shut down mid-sentence can still have one event in flight.
tb.Update(forTab{id: gone, msg: evText{text: "too late"}})
if strings.Contains(tb.cur().m.tr.render(), "too late") {
t.Error("a dead tab's message was delivered to the survivor")
}
}
func TestAltDigitAndStepMoveAlongTheStrip(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
tb.Update(altKey('1'))
if tb.active != 0 {
t.Fatalf("alt+1 left active = %d, want 0", tb.active)
}
tb.Update(altKey('3'))
if tb.active != 2 {
t.Fatalf("alt+3 left active = %d, want 2", tb.active)
}
// A number with no tab under it does nothing rather than something.
tb.Update(altKey('9'))
if tb.active != 2 {
t.Errorf("alt+9 with three tabs left active = %d, want 2", tb.active)
}
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlPgDown})
if tb.active != 0 {
t.Errorf("ctrl+pgdown off the end = %d, want to wrap to 0", tb.active)
}
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlPgUp})
if tb.active != 2 {
t.Errorf("ctrl+pgup off the start = %d, want to wrap to 2", tb.active)
}
}
func TestClosingATabReleasesItAndPicksAnother(t *testing.T) {
tb, _, closed := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
tb.Update(altKey('w'))
if got := len(tb.list); got != 1 {
t.Fatalf("tabs after alt+w = %d, want 1", got)
}
if *closed != 1 {
t.Errorf("Close called %d times, want 1 — a closed tab's supervisor is still running", *closed)
}
if tb.active != 0 {
t.Errorf("active = %d, want 0", tb.active)
}
// Back to one tab: the strip goes away and the row comes back.
if tb.cur().m.height != 24 {
t.Errorf("height after the strip went = %d, want 24", tb.cur().m.height)
}
}
func TestClosingTheLastTabLeavesCodey(t *testing.T) {
tb, _, _ := newTestTabs(t)
_, cmd := tb.Update(closeTabMsg{})
if !tb.cur().m.quitting {
t.Fatal("closing the only tab did not ask to leave")
}
if cmd == nil {
t.Fatal("no command came back from closing the last tab")
}
if _, ok := cmd().(tea.QuitMsg); !ok {
t.Error("closing the last tab did not quit the program")
}
}
func TestABusyTabRefusesToClose(t *testing.T) {
tb, _, closed := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
tb.cur().m.phase = phaseRunning
tb.Update(altKey('w'))
if got := len(tb.list); got != 2 {
t.Fatalf("tabs = %d, want the busy one still open", got)
}
if *closed != 0 {
t.Error("a running tab was torn down under the turn")
}
if !strings.Contains(tb.cur().m.tr.render(), "interrupt it") {
t.Error("nothing was said about why the tab stayed")
}
}
func TestTheWizardKeepsItsKeys(t *testing.T) {
tb, opened, _ := newTestTabs(t)
tb.cur().m.wiz = &wizard{}
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
if *opened != 0 || len(tb.list) != 1 {
t.Error("ctrl+t opened a tab behind the wizard, which owns the whole screen")
}
}
func TestTabTakesItsNameFromWhatWasAsked(t *testing.T) {
tb, _, _ := newTestTabs(t)
m := tb.cur().m
m.startTurn("fix the parser\nand the lexer", "fix the parser")
if m.title != "fix the parser" {
t.Fatalf("title = %q, want the first line of the prompt", m.title)
}
// It is the opening prompt, not the latest one: a tab that renamed itself
// every turn is a tab you have to find again every turn.
m.startTurn("now do the other thing", "now do the other thing")
if m.title != "fix the parser" {
t.Errorf("title = %q, want it unchanged by the second turn", m.title)
}
}
func TestClickingTheStripSwitchesTabs(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
tb.Update(tea.MouseMsg{X: 1, Y: 0, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft})
if tb.active != 0 {
t.Errorf("a click on the first label left active = %d, want 0", tb.active)
}
}
func TestMouseBelowTheStripIsMovedIntoTheConversation(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
m := tb.cur().m
m.tr.addUser("a line to click on")
m.sync()
// The strip is a row the conversation does not know about; what it is
// asked about has to be the row inside its own frame.
before := m.vp.YOffset
tb.Update(tea.MouseMsg{X: 2, Y: 3, Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp})
if m.vp.YOffset > before {
t.Error("a wheel below the strip did something other than scroll the conversation")
}
}
func TestSpeaksIsAboutThingsWorthSaying(t *testing.T) {
if speaks(evUsage{}) {
t.Error("token counts marked a tab; they arrive constantly and say nothing")
}
if !speaks(approvalMsg{}) {
t.Error("an approval waiting in another tab is exactly what a mark is for")
}
}
func TestACommandFromATabIsAddressedToIt(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
m := tb.list[1].m
msg := m.tag(func() tea.Msg { return turnDone{} })()
env, ok := msg.(forTab)
if !ok {
t.Fatalf("a tab's command produced %T, not a message addressed to a tab", msg)
}
if env.id != m.id {
t.Errorf("addressed to tab %d, want %d", env.id, m.id)
}
// A model with no strip around it — a test, or the single-session case —
// sends what it always sent.
plain := newTestModel(t)
if _, wrapped := plain.tag(func() tea.Msg { return turnDone{} })().(forTab); wrapped {
t.Error("an untabbed model wrapped its messages")
}
}
func TestALocalRunInABackgroundTabComesBackToIt(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
second := tb.list[1].m
second.opts.Root = t.TempDir()
cmd := second.runLocal(localShell, "echo hello")
// Back to the first tab: the command is still running in the second.
tb.Update(altKey('1'))
for _, msg := range drain(t, second, cmd) {
if env, ok := msg.(forTab); ok && env.id == second.id {
if _, isDone := env.msg.(localDone); isDone {
return
}
}
}
t.Fatal("the finished command did not come back addressed to the tab that started it")
}
func TestAnApprovalWaitingInAnotherTabIsSaidOutLoud(t *testing.T) {
tb, _, _ := newTestTabs(t)
tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
background := tb.list[0].m
tb.Update(forTab{id: background.id, msg: approvalMsg{}})
// The agent that asked is stopped until it is answered, so a mark on a tab
// is not enough: the tab you are in has to say where the question is.
if !strings.Contains(tb.cur().m.tr.render(), "tab 1 is waiting") {
t.Error("nothing said that another tab is blocked on an approval")
}
}