package tui import ( "context" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/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") } } // A first run opens the wizard from Init, before anything knows how big the // terminal is. The size arrives at the strip, which lays its conversations out // by calling resize rather than by passing the message down — so if that path // does not reach the wizard, the first thing a new install sees is an empty // screen it can only escape with ctrl+c. func TestFirstRunWizardIsSizedThroughTheStrip(t *testing.T) { tb := newTabs(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), FirstRun: true, }) tb.attach(&recorder{}) tb.Init() tb.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m := tb.cur().m if m.wiz == nil { t.Fatal("a first run did not open the wizard") } if m.wiz.width != 100 || m.wiz.height != 40 { t.Errorf("the wizard is %dx%d, want the terminal's 100x40", m.wiz.width, m.wiz.height) } if strings.TrimSpace(tb.View()) == "" { t.Fatal("the wizard drew nothing: it was never told how big the terminal is") } } 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") } }