package tui
import (
"context"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"codey/internal/agent"
"codey/internal/approval"
"codey/internal/llm"
"codey/internal/metrics"
"codey/internal/subagent"
"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)
}
}
}