package tui import ( "context" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/johannSo/codey/internal/agent" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/codey/internal/llm" "github.com/johannSo/codey/internal/metrics" "github.com/johannSo/codey/internal/subagent" "github.com/johannSo/codey/internal/tools" ) // fakeAgents stands in for the supervisor: the TUI only ever asks it for a // snapshot, takes finished reports off it, and tells it to pause, resume or // kill by id. type fakeAgents struct { rows []subagent.Info reports []subagent.Info acted []string // onAct fires inside Pause/Resume/Kill, standing in for the real // supervisor's Changed callback — which reaches the program through an // unbuffered channel the update loop is the only reader of. onAct func() // unclaimable are reports the supervisor has already handed to the model // another way, which the front end must not send again. unclaimable map[string]bool } func (f *fakeAgents) Snapshot() []subagent.Info { return f.rows } func (f *fakeAgents) TakeReports() []subagent.Info { r := f.reports; f.reports = nil; return r } func (f *fakeAgents) Live() int { var n int for _, r := range f.rows { if r.Status.Live() { n++ } } return n } func (f *fakeAgents) act(what string) error { f.acted = append(f.acted, what) if f.onAct != nil { f.onAct() } return nil } func (f *fakeAgents) Pause(id string) error { return f.act("pause " + id) } func (f *fakeAgents) Resume(id string) error { return f.act("resume " + id) } func (f *fakeAgents) Kill(id string) error { return f.act("kill " + id) } func (f *fakeAgents) Subscribe(func(subagent.Event), func()) {} // ClaimReport refuses a report the model has already been given another way. func (f *fakeAgents) ClaimReport(id string) bool { return !f.unclaimable[id] } func info(id, preset string, status subagent.Status) subagent.Info { return subagent.Info{ ID: id, Preset: preset, Model: "claude-haiku-4-5", Task: "do the thing", Status: status, Turns: 3, Started: time.Now().Add(-20 * time.Second), Usage: llm.Usage{InputTokens: 1000, OutputTokens: 200}, } } // newAgentModel is a model with a sidebar to drive. func newAgentModel(t *testing.T, rows ...subagent.Info) (*model, *fakeAgents) { t.Helper() f := &fakeAgents{rows: rows} m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), Agents: f, }) m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m.Update(evAgents{}) return m, f } func TestCtrlBOpensAndClosesTheSidebar(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) if strings.Contains(m.View(), "explore") { t.Fatal("the sidebar is showing before it was opened") } m.Update(key("ctrl+b")) if !strings.Contains(m.View(), "explore") { t.Fatalf("ctrl+b did not open the sidebar:\n%s", m.View()) } m.Update(key("ctrl+b")) if strings.Contains(m.View(), "explore") { t.Error("ctrl+b did not close the sidebar again") } } // The row is what tells you whether an agent is working or stuck, so it has to // carry the id, the preset, the model and the numbers. func TestARowNamesTheAgentAndWhatItCosts(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) view := m.View() for _, want := range []string{"a1", "explore", "Haiku", "3 turns"} { if !strings.Contains(view, want) { t.Errorf("the sidebar never says %q:\n%s", want, view) } } } func TestEveryStatusIsLegibleInTheSidebar(t *testing.T) { rows := []subagent.Info{ info("a1", "explore", subagent.Queued), info("a2", "plan", subagent.Running), info("a3", "general", subagent.Waiting), info("a4", "explore", subagent.Paused), info("a5", "plan", subagent.Done), info("a6", "general", subagent.Failed), info("a7", "explore", subagent.Killed), } m, _ := newAgentModel(t, rows...) m.Update(key("ctrl+b")) view := m.View() for _, r := range rows { if !strings.Contains(view, r.ID) { t.Errorf("%s (%s) is missing from the sidebar:\n%s", r.ID, r.Status, view) } } for _, word := range []string{"queued", "waiting", "paused", "done", "failed", "stopped"} { if !strings.Contains(view, word) { t.Errorf("no row reads as %q:\n%s", word, view) } } } // Selecting an agent shows its own transcript in the main pane: the one place // the turns it took are visible at all. func TestOpeningAnAgentShowsItsOwnThread(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running), info("a2", "plan", subagent.Running)) m.tr.addUser("what my own thread says") m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore", Agent: agent.Event{Kind: agent.EventText, Text: "what the first agent said"}}}) m.Update(evSub{ev: subagent.Event{ID: "a2", Preset: "plan", Agent: agent.Event{Kind: agent.EventText, Text: "what the second agent said"}}}) m.Update(key("ctrl+b")) m.Update(key("enter")) view := m.View() if !strings.Contains(view, "what the first agent said") { t.Fatalf("a1's thread is not on screen:\n%s", view) } if strings.Contains(view, "what my own thread says") { t.Error("the main thread is still on screen underneath the agent's") } m.Update(key("down")) m.Update(key("enter")) view = m.View() if !strings.Contains(view, "what the second agent said") { t.Fatalf("moving to a2 did not show its thread:\n%s", view) } if strings.Contains(view, "what the first agent said") { t.Error("a1's thread is still showing while a2 is selected") } } func TestEscLeavesTheThreadThenClosesTheSidebar(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.tr.addUser("my own thread") m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore", Agent: agent.Event{Kind: agent.EventText, Text: "the agent's thread"}}}) m.Update(key("ctrl+b")) m.Update(key("enter")) m.Update(key("esc")) if m.viewing != "" { t.Errorf("still viewing %q after esc", m.viewing) } if !strings.Contains(m.View(), "explore") { t.Error("esc closed the whole sidebar rather than leaving the thread") } if !strings.Contains(m.View(), "my own thread") { t.Error("esc did not put your own thread back") } m.Update(key("esc")) if strings.Contains(m.View(), "explore") { t.Error("a second esc did not close the sidebar") } } func TestPauseResumeAndKillActOnTheSelectedAgent(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Running), info("a2", "plan", subagent.Paused), ) m.Update(key("ctrl+b")) pressKey(t, m, key("p")) // a1 is running, so p pauses it pressKey(t, m, key("down")) pressKey(t, m, key("p")) // a2 is paused, so p resumes it pressKey(t, m, key("k")) if strings.Join(f.acted, ", ") != "pause a1, resume a2, kill a2" { t.Errorf("the sidebar asked for %v", f.acted) } } // Pausing or killing must happen off the update loop. The supervisor tells // the front end that something changed by sending it a message, and the // channel that carries it is read by the update loop itself — so a supervisor // call made from inside Update deadlocks the whole program, keyboard included. func TestControllingAnAgentHappensOffTheUpdateLoop(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) var inUpdate, calledInUpdate bool f.onAct = func() { if inUpdate { calledInUpdate = true } } inUpdate = true _, cmd := m.Update(key("p")) inUpdate = false if calledInUpdate { t.Fatal("the supervisor was called from inside Update, which deadlocks the program") } if cmd == nil { t.Fatal("pressing p produced no command, so nothing will ever pause the agent") } drain(t, m, cmd) if len(f.acted) != 1 || f.acted[0] != "pause a1" { t.Errorf("after the command ran, the supervisor saw %v", f.acted) } } // pressKey feeds a key and then runs whatever command came back, the way the // program's scheduler would. func pressKey(t *testing.T, m *model, msg tea.Msg) { t.Helper() _, cmd := m.Update(msg) drain(t, m, cmd) } // While the sidebar has the keys, what you type must not land in the prompt — // otherwise "k" both kills an agent and types a letter. func TestKeysGoToTheSidebarNotThePromptWhileItIsOpen(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) typeIn(m, "pk") if got := m.input.Value(); got != "" { t.Errorf("the prompt swallowed %q while the sidebar was open", got) } m.Update(key("esc")) typeIn(m, "hello") if got := m.input.Value(); got != "hello" { t.Errorf("after esc the prompt has %q, want %q", got, "hello") } } // A subagent's events belong to its own transcript, not to yours. func TestSubagentOutputNeverLandsInTheMainTranscript(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore", Agent: agent.Event{Kind: agent.EventToolStart, Tool: "grep", Input: []byte(`{"pattern":"x"}`)}}}) if strings.Contains(m.tr.render(), "grep") { t.Errorf("the agent's tool call landed in the main transcript:\n%s", m.tr.render()) } } // A finished agent is worth a line in your own transcript even when its report // is not being delivered yet: an agent that finishes invisibly is one you go // looking for. func TestAFinishedAgentIsNoticedInTheMainTranscript(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Running)) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.rows = []subagent.Info{done} f.reports = []subagent.Info{done} typeIn(m, "half a sentence") // holds the report back m.Update(evAgents{}) if !strings.Contains(m.tr.render(), "a1") { t.Errorf("nothing in the transcript says a1 finished:\n%s", m.tr.render()) } } // A turn must not start under your fingers: it blurs the prompt and eats what // you were typing. func TestAReportIsHeldWhileYouAreTyping(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) f.reports = []subagent.Info{info("a1", "explore", subagent.Done)} typeIn(m, "half a sentence") m.Update(evAgents{}) if len(m.pendingReports) != 1 { t.Fatalf("the report was not held: %d pending", len(m.pendingReports)) } if m.phase != phaseIdle { t.Errorf("a turn started while the prompt had text in it") } if got := m.input.Value(); got != "half a sentence" { t.Errorf("the prompt now reads %q", got) } } func TestReportsAreHeldWhileAnApprovalIsOnScreen(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) f.reports = []subagent.Info{info("a1", "explore", subagent.Done)} pend(m, approval.Request{Tool: "bash", Summary: "run something"}) m.Update(evAgents{}) if len(m.pendingReports) != 1 { t.Fatalf("the report was not held while an approval was up") } } // Idle and empty-handed, the report goes into the conversation by itself and // the agent is set going on it — no keystroke required. func TestAReportStartsATurnWhenYouAreIdle(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) m.opts.Agent = newQuietAgent(t) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.reports = []subagent.Info{done} m.Update(evAgents{}) if len(m.pendingReports) != 0 { t.Fatalf("the report is still pending with nothing in the way") } if m.phase != phaseRunning { t.Errorf("phase = %v, want a turn to have started", m.phase) } var sent string for _, msg := range m.opts.Agent.State().History() { for _, b := range msg.Content { sent += b.Text } } if !strings.Contains(sent, "found it in loop.go") { t.Errorf("the conversation never got the report:\n%s", sent) } } // Whatever was typed before the report landed is still yours to send, and the // report goes up ahead of it rather than being glued onto it. func TestAHeldReportGoesUpWhenYouSendYourOwnMessage(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) m.opts.Agent = newQuietAgent(t) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.reports = []subagent.Info{done} typeIn(m, "and now fix it") m.Update(evAgents{}) m.Update(key("enter")) history := m.opts.Agent.State().History() if len(history) != 1 { t.Fatalf("history has %d messages, want the report as its own message", len(history)) } if !strings.Contains(history[0].Content[0].Text, "found it in loop.go") { t.Errorf("the report did not go up ahead of the message:\n%+v", history[0]) } if len(m.pendingReports) != 0 { t.Error("the report is still pending after a turn started") } } // newQuietAgent is a real agent whose turns are never actually run: the tests // above stop at the command, so nothing reaches the network. func newQuietAgent(t *testing.T) *agent.Agent { t.Helper() log := metrics.NewWriter(nil, "test") state := agent.NewState() reg := tools.NewRegistry(log) return agent.New(llm.New(llm.WithAPIKey("x")), reg, state, log, agent.Options{}) } // A sub-cent agent showing "$0.00" reads as free. Below a cent the row says // nothing, and the line in the transcript gives the exact figure. func TestSubCentCostsAreNotShownAsZero(t *testing.T) { cheap := info("a1", "explore", subagent.Done) cheap.Usage = llm.Usage{InputTokens: 1000, OutputTokens: 40} dear := info("a2", "plan", subagent.Done) dear.Model = "claude-opus-5" dear.Usage = llm.Usage{InputTokens: 100000, OutputTokens: 4000} m, _ := newAgentModel(t, cheap, dear) m.Update(key("ctrl+b")) view := m.View() if strings.Contains(view, "$0.00") { t.Errorf("a sub-cent agent is shown as costing nothing:\n%s", view) } if !strings.Contains(view, "$0.6") { t.Errorf("the expensive agent's cost is missing:\n%s", view) } } // Thirty columns is not enough for "claude-haiku-4-5" and a duration and a // turn count, so the row carries the family and the thread's header carries // the rest. func TestShortModelKeepsTheFamily(t *testing.T) { cases := map[string]string{ "claude-haiku-4-5": "Haiku", "claude-opus-5": "Opus", "claude-haiku-4-5-20251001": "Haiku", "some-gateway/mistral-large": "some-gateway/mistral-large", "": "", } for id, want := range cases { if got := shortModel(id); got != want { t.Errorf("shortModel(%q) = %q, want %q", id, got, want) } } } // A minute in, "124s" stops being a number anyone reads as a duration. func TestCompactDurationTurnsMinutesIntoMinutes(t *testing.T) { cases := []struct { d time.Duration want string }{ {3 * time.Second, "3s"}, {59 * time.Second, "59s"}, {time.Minute + 4*time.Second, "1m04s"}, {124 * time.Second, "2m04s"}, {2 * time.Hour, "120m00s"}, } for _, c := range cases { if got := compactDuration(c.d); got != c.want { t.Errorf("compactDuration(%v) = %q, want %q", c.d, got, c.want) } } } // The pane is the full height of the screen: two short rows leave the status // line running under it with nothing beside it. func TestTheSidebarIsAsTallAsTheScreen(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(tea.WindowSizeMsg{Width: 78, Height: 16}) m.Update(key("ctrl+b")) for i, line := range strings.Split(m.View(), "\n") { if !strings.Contains(line, "│") { t.Errorf("line %d has no sidebar beside it: %q", i, line) } } } // Nothing the model draws may be wider than the terminal it is drawn in: a // composite that overflows wraps, and a wrapped full-screen layout tears. func TestTheWholeScreenFitsTheTerminalWithTheSidebarOpen(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) for _, size := range []tea.WindowSizeMsg{{Width: 78, Height: 14}, {Width: 100, Height: 30}, {Width: 62, Height: 20}} { m.Update(size) m.Update(evAgents{}) for _, open := range []bool{true, false} { if m.bar.open != open { m.Update(key("ctrl+b")) } for i, line := range strings.Split(m.View(), "\n") { if w := lipgloss.Width(line); w > size.Width { t.Errorf("at %dx%d (sidebar open=%v) line %d is %d columns wide", size.Width, size.Height, open, i, w) } } } } } // The sidebar owns the keyboard while it is open, with two exceptions: the // keys that stop things. Swallowing ctrl+c would mean opening the pane to // watch an agent left you unable to interrupt your own turn. func TestInterruptAndExitStillWorkWithTheSidebarOpen(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.phase = phaseRunning var interrupted bool m.cancel = func() { interrupted = true } m.Update(key("ctrl+b")) m.Update(key("ctrl+c")) if !interrupted { t.Error("ctrl+c did not interrupt the turn while the sidebar was open") } m.phase = phaseIdle m.Update(key("ctrl+d")) if !m.quitting { t.Error("ctrl+d did not exit while the sidebar was open") } } func TestClickingTheAgentsPillTogglesTheSidebar(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.View() // the pill's position is recorded as it is drawn if m.pill.x1 == 0 { t.Fatal("the agents pill was never drawn, so it cannot be clicked") } // The status line is the second row from the bottom; the mode line is // under it. statusRow := m.height - 2 m.Update(press(m.pill.x0, statusRow)) if !m.bar.open { t.Error("clicking the pill did not open the sidebar") } m.View() m.Update(press(m.pill.x0, statusRow)) if m.bar.open { t.Error("clicking the pill again did not close it") } } // One click selects a row; clicking the row already under the cursor opens it, // which is what anyone who double-clicks gets. func TestClickingARowSelectsItAndClickingAgainOpensIt(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running), info("a2", "plan", subagent.Running), ) m.Update(key("ctrl+b")) m.View() if len(m.bar.hits) != 2 { t.Fatalf("the sidebar recorded %d clickable rows, want 2", len(m.bar.hits)) } second := m.bar.hits[1] x := m.width - sidebarWidth + 2 m.Update(press(x, second.y0)) if cur, _ := m.bar.selected(); cur.ID != "a2" { t.Fatalf("clicking a2's row selected %q", cur.ID) } if m.viewing != "" { t.Error("one click opened the thread; it should only select") } m.View() m.Update(press(x, second.y0)) if m.viewing != "a2" { t.Errorf("clicking the selected row did not open it; viewing = %q", m.viewing) } } // A click in the transcript is not a click in the sidebar, however far right // it lands while the pane is closed. func TestClicksOutsideTheSidebarDoNothing(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.View() m.Update(press(m.width-2, 3)) if m.bar.open || m.viewing != "" { t.Error("a click in the transcript opened the sidebar") } } func TestMouseCommandTogglesReporting(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.mouseOn = true m.submit("/mouse") if m.mouseOn { t.Error("/mouse did not turn reporting off") } if !strings.Contains(m.tr.render(), "mouse off") { t.Errorf("nothing said what happened:\n%s", m.tr.render()) } m.submit("/mouse") if !m.mouseOn { t.Error("/mouse did not turn reporting back on") } } // The rows' click targets have to agree with where the rows were actually // drawn. Recorded coordinates that are internally consistent but a line off // the screen would make every click select the row above. func TestRowClickTargetsMatchWhereTheRowsAreDrawn(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running), info("a2", "plan", subagent.Running), info("a3", "general", subagent.Done), ) m.Update(key("ctrl+b")) lines := strings.Split(m.View(), "\n") if len(m.bar.hits) != 3 { t.Fatalf("recorded %d click targets, want 3", len(m.bar.hits)) } for _, h := range m.bar.hits { if h.y0 >= len(lines) { t.Fatalf("row %s is recorded at line %d of a %d-line screen", h.id, h.y0, len(lines)) } if !strings.Contains(lines[h.y0], h.id) { t.Errorf("row %s is recorded at line %d, which reads %q", h.id, h.y0, lines[h.y0]) } } } // The same for the pill: the columns it recorded are inside the line it was // drawn in. func TestThePillIsRecordedWhereItIsDrawn(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) lines := strings.Split(m.View(), "\n") status := lines[m.height-2] if !strings.Contains(status, "agents") { t.Fatalf("the status line does not carry the pill: %q", status) } if m.pill.x0 <= 0 || m.pill.x1 >= lipgloss.Width(status) { t.Errorf("the pill is recorded at columns %d-%d of a %d-wide line", m.pill.x0, m.pill.x1, lipgloss.Width(status)) } } // A subagent's approval can arrive when nothing of yours is running, and // answering it must leave the session where it found it. Dropping into // "running" with no turn in flight strands the UI: enter is refused, esc only // says "interrupting", and no further report is ever delivered. func TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) reply := pend(m, approval.Request{Tool: "bash", Summary: "run something", Agent: "a1"}) if m.phase != phaseApproving { t.Fatalf("phase = %v while an approval is up", m.phase) } m.Update(key("y")) if got := <-reply; got != approval.Once { t.Fatalf("decision = %v", got) } if m.phase != phaseIdle { t.Errorf("phase = %v after answering; nothing of yours was running", m.phase) } // The prompt is usable again, which is the thing being stranded costs you. typeIn(m, "next") if m.input.Value() != "next" { t.Errorf("the prompt has %q", m.input.Value()) } } // The same approval answered during a turn puts the turn back. func TestAnsweringAnApprovalDuringATurnStaysRunning(t *testing.T) { m, _ := newAgentModel(t) m.phase = phaseRunning reply := pend(m, approval.Request{Tool: "bash", Summary: "run something"}) m.Update(key("y")) <-reply if m.phase != phaseRunning { t.Errorf("phase = %v, want the turn to carry on", m.phase) } } // A turn ending cancels the approvals that belonged to it — but a subagent's // approval belongs to the subagent, which is still running. Denying it because // the parent finished takes the decision away from the user and tells the // child it was refused. func TestATurnEndingLeavesSubagentApprovalsAlone(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.phase = phaseRunning mine := pend(m, approval.Request{Tool: "bash", Summary: "the parent's"}) child := pend(m, approval.Request{Tool: "bash", Summary: "the child's", Agent: "a1"}) m.Update(turnDone{}) select { case got := <-mine: if got != approval.Deny { t.Errorf("the parent's own request ended as %v, want denied", got) } default: t.Error("the parent's request was left hanging after its turn ended") } select { case got := <-child: t.Fatalf("the child's request was answered %v by a turn it had nothing to do with", got) default: } if len(m.pending) != 1 || m.pending[0].req.Agent != "a1" { t.Fatalf("pending = %+v, want the child's request still waiting", m.pending) } if m.phase != phaseApproving { t.Errorf("phase = %v, want the box still up for the child", m.phase) } } // A report the agent already collected with agent_wait is worth a line in the // transcript, and nothing more: sending it again would repeat it in the // conversation and start a turn to acknowledge something it just read. func TestAReportTheAgentAlreadyHasIsNotSentAgain(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) m.opts.Agent = newQuietAgent(t) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.unclaimable = map[string]bool{"a1": true} f.reports = []subagent.Info{done} m.Update(evAgents{}) if len(m.pendingReports) != 0 { t.Errorf("%d reports are queued for a conversation that already has them", len(m.pendingReports)) } if m.phase != phaseIdle { t.Error("a turn was started to hand over a report the agent already read") } if len(m.opts.Agent.State().History()) != 0 { t.Error("the report went into the conversation twice") } if !strings.Contains(m.tr.render(), "a1") { t.Errorf("the transcript never mentions that a1 finished:\n%s", m.tr.render()) } } // With several agents running, "bash wants to run …" is not a question anyone // can answer: the box has to say who is asking. func TestTheApprovalBoxNamesTheAgentAsking(t *testing.T) { m, _ := newAgentModel(t, info("a3", "explore", subagent.Running)) pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "rm -rf build", Agent: "a3"}) view := m.View() if !strings.Contains(view, "a3") { t.Errorf("the approval box does not say which agent is asking:\n%s", view) } if !strings.Contains(view, "explore") { t.Errorf("the approval box does not say what kind of agent it is:\n%s", view) } } // Your own agent's requests are not labelled: there is nobody else it could be. func TestYourOwnApprovalIsNotLabelledWithAnAgent(t *testing.T) { m, _ := newAgentModel(t) m.phase = phaseRunning pend(m, approval.Request{Tool: "bash", Summary: "run a shell command"}) if got := m.View(); strings.Contains(got, " · bash") { t.Errorf("the box labels the session's own request as if it came from an agent:\n%s", got) } } // On a terminal too narrow for both panes the status line is not drawn, so the // pill is nowhere — and a click at its remembered position must not toggle the // pane shut from under a hint line that happens to be there instead. func TestThePillIsForgottenWhenTheStatusLineIsNotDrawn(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.View() was := m.pill m.Update(tea.WindowSizeMsg{Width: 50, Height: 20}) m.Update(key("ctrl+b")) if !m.fullScreenSidebar() { t.Fatal("50 columns should not be showing both panes") } m.View() m.Update(press(was.x0, m.height-2)) if !m.bar.open { t.Error("a click where the pill used to be closed the pane") } } // A report held back because you were typing goes up as soon as the prompt is // empty again, rather than waiting for something else to happen. func TestAHeldReportGoesUpWhenThePromptIsClearedAgain(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) m.opts.Agent = newQuietAgent(t) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.reports = []subagent.Info{done} typeIn(m, "ab") m.Update(evAgents{}) if len(m.pendingReports) != 1 { t.Fatal("the report was not held while the prompt had text in it") } m.Update(key("backspace")) m.Update(key("backspace")) if len(m.pendingReports) != 0 { t.Errorf("clearing the prompt did not release the report") } if m.phase != phaseRunning { t.Errorf("phase = %v, want the turn to have started", m.phase) } } // The effort pill is a claim about what the next turn will ask for. On a model // that does not take the level, the request goes out without it — so the pill // has to say so rather than reporting a setting that is not in force. func TestTheEffortPillSaysWhenTheModelIgnoresIt(t *testing.T) { m, _ := newAgentModel(t) m.opts.Effort = "high" m.opts.Client.SetModel("claude-opus-5") if got := m.View(); !strings.Contains(got, "● high") || strings.Contains(got, "not on") { t.Errorf("on a model that takes effort the pill should read plainly:\n%s", got) } m.opts.Client.SetModel("claude-haiku-4-5") view := m.View() if !strings.Contains(view, "high") { t.Fatalf("the pill vanished entirely:\n%s", view) } if !strings.Contains(view, "not on Haiku") { t.Errorf("the pill still claims a level the model rejects:\n%s", view) } } // The front end holds a report from the moment it is drained until the moment // there is a turn to put it in, and agent_wait can hand the same report to the // model in between. So delivery is decided when the message is written, by // asking the supervisor — not by trusting a copy taken minutes earlier. func TestAReportTheModelWasGivenMeanwhileIsNotInjected(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) m.opts.Agent = newQuietAgent(t) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.reports = []subagent.Info{done} // It is held back while the prompt has text in it — which is where the // parent's own agent_wait call collects it. typeIn(m, "hold on") m.Update(evAgents{}) if len(m.pendingReports) != 1 { t.Fatalf("the report was not held: %d pending", len(m.pendingReports)) } f.unclaimable = map[string]bool{"a1": true} m.Update(key("backspace")) typeIn(m, "") for i := 0; i < 7; i++ { m.Update(key("backspace")) } if len(m.pendingReports) != 0 { t.Errorf("%d reports are still queued", len(m.pendingReports)) } if n := len(m.opts.Agent.State().History()); n != 0 { t.Errorf("the conversation got %d messages for a report the model already had", n) } if m.phase != phaseIdle { t.Error("a turn was started to hand over a report the model already had") } } // The same path when nobody else has taken it: the report goes up. func TestAnUnclaimedReportIsStillInjected(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Done)) m.opts.Agent = newQuietAgent(t) done := info("a1", "explore", subagent.Done) done.Report = "found it in loop.go" f.reports = []subagent.Info{done} m.Update(evAgents{}) if len(m.pendingReports) != 0 { t.Errorf("the report was not delivered: %d pending", len(m.pendingReports)) } history := m.opts.Agent.State().History() if len(history) != 1 || !strings.Contains(history[0].Content[0].Text, "found it in loop.go") { t.Errorf("the conversation did not get the report: %+v", history) } } // The thread header is where an agent's settings are legible — the row has no // space for them — so it names the effort the agent is actually running at. func TestTheThreadHeaderNamesTheEffort(t *testing.T) { low := info("a1", "explore", subagent.Running) low.Effort = "low" plain := info("a2", "plan", subagent.Running) m, _ := newAgentModel(t, low, plain) m.Update(key("ctrl+b")) m.Update(key("enter")) if got := m.agentHeader(80); !strings.Contains(got, "low") { t.Errorf("a1's header does not say what effort it runs at: %q", got) } m.Update(key("down")) if got := m.agentHeader(80); strings.Contains(got, "effort") { t.Errorf("a2's header invents an effort level: %q", got) } } // The pane owns the keyboard while it has focus, which is what makes k a kill // rather than a letter — but the prompt has to be reachable without closing // it. Clicking the prompt is how you get back to typing. func TestClickingThePromptTakesFocusFromTheSidebar(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) typeIn(m, "no") if m.input.Value() != "" { t.Fatalf("the prompt took %q while the pane had focus", m.input.Value()) } // A click anywhere in the main column — the prompt box included — is a // click on the conversation, and that is where typing should go. m.View() m.Update(press(4, m.height-4)) if !m.bar.open { t.Error("clicking the prompt closed the sidebar; it should only move focus") } typeIn(m, "hello") if got := m.input.Value(); got != "hello" { t.Errorf("the prompt has %q after being clicked", got) } } // And clicking back into the pane gives it the keys again. func TestClickingTheSidebarTakesFocusBack(t *testing.T) { m, f := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) m.View() m.Update(press(4, m.height-4)) // to the prompt m.View() x := m.width - sidebarWidth + 2 m.Update(press(x, m.bar.hits[0].y0)) pressKey(t, m, key("p")) if len(f.acted) != 1 || f.acted[0] != "pause a1" { t.Errorf("p did not reach the pane after clicking it: %v", f.acted) } if m.input.Value() != "" { t.Errorf("the prompt took the keystroke too: %q", m.input.Value()) } } // ctrl+b on a pane that is open but not focused takes you to it rather than // closing it: the pane is right there, and closing what you are looking at is // never what that key was for. func TestCtrlBFocusesAnOpenPaneBeforeClosingIt(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) // open, focused m.View() m.Update(press(4, m.height-4)) // focus the prompt if !m.bar.open || m.bar.focused { t.Fatalf("after clicking the prompt: open=%v focused=%v", m.bar.open, m.bar.focused) } m.Update(key("ctrl+b")) if !m.bar.open || !m.bar.focused { t.Errorf("ctrl+b on an unfocused pane gave open=%v focused=%v", m.bar.open, m.bar.focused) } m.Update(key("ctrl+b")) if m.bar.open { t.Error("ctrl+b on a focused pane did not close it") } } // Which pane has the keys has to be visible, or the next keystroke is a guess. func TestTheScreenSaysWhichPaneHasTheKeys(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.Update(key("ctrl+b")) focused := m.View() if !strings.Contains(focused, "esc back") { t.Errorf("a focused pane does not offer its keys:\n%s", focused) } m.View() m.Update(press(4, m.height-4)) unfocused := m.View() if !strings.Contains(unfocused, "ctrl+b") { t.Errorf("an unfocused pane does not say how to get back to it:\n%s", unfocused) } if strings.Contains(unfocused, "p pause") { t.Errorf("an unfocused pane still offers keys it will not answer:\n%s", unfocused) } } // A turn ending puts the keyboard back in the prompt — unless the pane has it, // in which case taking it away mid-keystroke would be the bug this focus // separation exists to avoid. func TestATurnEndingDoesNotStealFocusFromThePane(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.opts.SessionDir = t.TempDir() m.Update(key("ctrl+b")) m.phase = phaseRunning m.Update(turnDone{}) if !m.bar.focused { t.Fatal("the pane lost focus when a turn ended") } typeIn(m, "no") if got := m.input.Value(); got != "" { t.Errorf("the prompt took %q after the turn ended", got) } } // Selecting inside an agent's thread selects what is on screen. The selection // machinery was written when there was only ever one transcript. func TestSelectingInsideAnAgentsThreadCopiesThatThread(t *testing.T) { m, _ := newAgentModel(t, info("a1", "explore", subagent.Running)) m.tr.addUser("my own conversation") m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore", Agent: agent.Event{Kind: agent.EventText, Text: "what the agent found"}}}) m.Update(key("ctrl+b")) m.Update(key("enter")) m.View() // A drag across the first line of what is on screen. m.Update(press(0, 1)) m.Update(motion(30, 1)) m.Update(release(30, 1)) lines := m.activeTranscript().lines() if from, to, ok := m.sel.span(lines); ok { if got := selectedText(lines, from, to); strings.Contains(got, "my own conversation") { t.Errorf("the selection came from the conversation underneath: %q", got) } } }