Vectrum
Sign In

tui: the agents sidebar

ctrl+b opens a pane on the right listing every subagent: status glyph, id and
preset, with the model, elapsed, turns and cost under it. Enter reads one
agent's own thread in the main pane — the only place its turns are visible at
all — and p and k pause and stop it.

Open and focused are separate. The pane answers every key while it has focus,
which is what lets k be a kill rather than a letter, so clicking the prompt
puts the keyboard back without closing what you were watching. Which pane is
live is visible in the heading, the prompt frame and the pane's own footer.

Reports arrive by themselves: a finished agent's report goes into the
conversation and the agent is set going on it, held back only while a turn is
running, an approval is up, a local command is running, or the prompt has text
in it — a turn starting under your fingers would eat what you were typing.

Two things the supervisor forced. Acting on an agent happens in a command
rather than in Update, because the supervisor answers by sending this program a
message and the channel that carries it is read by the update loop itself.
And an approval no longer implies a running turn: a subagent can ask while you
are idle, and answering has to put the session back where it found it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 3869bc2commit85bbaf0

Showing 13 changed files with 2376 additions and 52 deletions.

+195−0internal/tui/agents_live_test.goaddedView File
@@ -0,0 +1,195 @@
1+ package tui
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/charmbracelet/bubbles/spinner"
16+ tea "github.com/charmbracelet/bubbletea"
17+
18+ "harness/internal/agent"
19+ "harness/internal/approval"
20+ "harness/internal/llm"
21+ "harness/internal/metrics"
22+ "harness/internal/subagent"
23+ "harness/internal/tools"
24+ )
25+
26+ // scriptedAPI answers the main agent and its children differently, so one
27+ // server can play both halves of a spawn.
28+ func scriptedAPI(t *testing.T) *httptest.Server {
29+ t.Helper()
30+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+ body, _ := io.ReadAll(r.Body)
32+ var req llm.Request
33+ json.Unmarshal(body, &req)
34+
35+ var system string
36+ for _, b := range req.System {
37+ system += b.Text
38+ }
39+ var conversation string
40+ for _, m := range req.Messages {
41+ for _, b := range m.Content {
42+ conversation += b.Text + b.Content
43+ }
44+ }
45+
46+ switch {
47+ case strings.Contains(system, "You are a subagent"):
48+ io.WriteString(w, liveMsg("end_turn", `{"type":"text","text":"found it in internal/approval/policy.go:70"}`))
49+ case strings.Contains(conversation, "[subagent a1"):
50+ io.WriteString(w, liveMsg("end_turn", `{"type":"text","text":"thanks — that is the line I needed"}`))
51+ case strings.Contains(conversation, "started a1"):
52+ io.WriteString(w, liveMsg("end_turn", `{"type":"text","text":"an agent is on it"}`))
53+ default:
54+ io.WriteString(w, liveMsg("tool_use", `{"type":"tool_use","id":"tu_1","name":"spawn_agent",`+
55+ `"input":{"preset":"explore","task":"find where the approval policy is consulted"}}`))
56+ }
57+ }))
58+ t.Cleanup(srv.Close)
59+ return srv
60+ }
61+
62+ func liveMsg(stop string, blocks ...string) string {
63+ return `{"id":"msg","type":"message","role":"assistant","model":"m","content":[` +
64+ strings.Join(blocks, ",") + `],"stop_reason":"` + stop + `",` +
65+ `"usage":{"input_tokens":100,"output_tokens":20}}`
66+ }
67+
68+ // A whole spawn, end to end: the model asks for an agent, the supervisor runs
69+ // it, the sidebar fills in, the agent's own thread is readable, and its report
70+ // comes back into the conversation without anybody typing.
71+ func TestSpawningFillsTheSidebarAndTheReportComesBack(t *testing.T) {
72+ srv := scriptedAPI(t)
73+ root := t.TempDir()
74+ log := metrics.NewWriter(&bytes.Buffer{}, "test")
75+ client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"))
76+ policy := approval.New(nil)
77+ policy.YOLO = true // approvals have their own tests; this one is about the wiring
78+
79+ newTools := func(state *agent.State, log *metrics.Logger) *tools.Registry {
80+ return tools.NewRegistry(log).Register(tools.NewRead(root, state))
81+ }
82+ sup := subagent.New(context.Background(), subagent.Options{
83+ Client: client, Log: log, Policy: policy, Tools: newTools,
84+ })
85+ t.Cleanup(sup.Shutdown)
86+
87+ state := agent.NewState()
88+ reg := newTools(state, log)
89+ reg.Register(sup.Tools()...)
90+ a := agent.New(client, reg, state, log, agent.Options{})
91+ a.Approve = policy
92+
93+ m := newModel(context.Background(), Options{
94+ Agent: a, Client: client, Policy: policy, Agents: sup, Root: root,
95+ SessionDir: t.TempDir(), SessionID: "test",
96+ })
97+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
98+
99+ // Everything the supervisor and the agent report arrives here, and is
100+ // pumped into the model from this goroutine — which is exactly what
101+ // Bubble Tea's Send does in the real program.
102+ msgs := make(chan tea.Msg, 256)
103+ sup.Subscribe(
104+ func(ev subagent.Event) { msgs <- evSub{ev: ev} },
105+ func() { msgs <- evAgents{} },
106+ )
107+ a.Observe = func(ev agent.Event) {
108+ if ev.Kind == agent.EventToolResult {
109+ msgs <- evToolResult{tool: ev.Tool, result: ev.Result}
110+ }
111+ }
112+
113+ // Send a prompt the way the prompt box does, and run the commands it
114+ // returns off the update loop, as the program's scheduler would.
115+ d := &driver{msgs: msgs}
116+ // Everything the model started has to finish before the test's temporary
117+ // directories go away: a turn saves its session as it ends.
118+ defer d.wg.Wait()
119+
120+ _, cmd := m.submit("find where the approval policy is consulted")
121+ d.run(cmd)
122+
123+ deadline := time.After(10 * time.Second)
124+ var sawReport bool
125+ for !sawReport {
126+ select {
127+ case msg := <-msgs:
128+ _, next := m.Update(msg)
129+ d.run(next)
130+ sawReport = strings.Contains(historyText(a), "[subagent a1")
131+ case <-deadline:
132+ t.Fatalf("the report never came back. transcript:\n%s", m.tr.render())
133+ }
134+ }
135+
136+ // The sidebar knows about it, and the thread is readable.
137+ if len(m.bar.rows) != 1 || m.bar.rows[0].ID != "a1" {
138+ t.Fatalf("sidebar rows = %+v", m.bar.rows)
139+ }
140+ m.Update(key("ctrl+b"))
141+ m.Update(key("enter"))
142+ if view := m.View(); !strings.Contains(view, "internal/approval/policy.go:70") {
143+ t.Errorf("a1's own thread is not readable:\n%s", view)
144+ }
145+
146+ // And the main transcript said so in one line rather than repeating the
147+ // whole report.
148+ if !strings.Contains(m.tr.render(), "a1 (explore) finished") {
149+ t.Errorf("the main transcript never noticed a1 finishing:\n%s", m.tr.render())
150+ }
151+ }
152+
153+ // driver runs Bubble Tea commands off the update loop, the way the program's
154+ // own scheduler does, and keeps count of them so a test can wait for the work
155+ // it started. A batch is a list of commands rather than a message, so it is
156+ // unpacked here exactly as the real scheduler unpacks it.
157+ //
158+ // Spinner ticks are dropped: feeding one back produces another for ever, and
159+ // the animation is not what any of this is testing.
160+ type driver struct {
161+ wg sync.WaitGroup
162+ msgs chan tea.Msg
163+ }
164+
165+ func (d *driver) run(cmd tea.Cmd) {
166+ if cmd == nil {
167+ return
168+ }
169+ d.wg.Add(1)
170+ go func() {
171+ defer d.wg.Done()
172+ switch msg := cmd().(type) {
173+ case nil, tea.QuitMsg, spinner.TickMsg:
174+ case tea.BatchMsg:
175+ for _, c := range msg {
176+ d.run(c)
177+ }
178+ default:
179+ select {
180+ case d.msgs <- msg:
181+ default: // nobody is reading any more; the test is done
182+ }
183+ }
184+ }()
185+ }
186+
187+ func historyText(a *agent.Agent) string {
188+ var s string
189+ for _, m := range a.State().History() {
190+ for _, b := range m.Content {
191+ s += b.Text
192+ }
193+ }
194+ return s
195+ }
+23−0internal/tui/bridge.goView File
@@ -8,6 +8,7 @@ import (
88 "harness/internal/agent"
99 "harness/internal/approval"
1010 "harness/internal/llm"
11+ "harness/internal/subagent"
1112 )
1213
1314 // The agent loop runs on its own goroutine and must not know a UI exists. It
@@ -38,6 +39,16 @@ type (
3839 evUsage struct{ usage llm.Usage }
3940 evCompact struct{}
4041
42+ // evSub is one subagent's event, tagged with the agent it came from.
43+ evSub struct{ ev subagent.Event }
44+ // agentActed comes back from a pause, resume or kill asked for in the
45+ // sidebar, carrying whatever the supervisor refused with.
46+ agentActed struct{ err error }
47+ // evAgents says a subagent's status moved or one finished. It carries
48+ // nothing: the model reads the supervisor, which is the one copy of the
49+ // truth, rather than a snapshot that was true when it was sent.
50+ evAgents struct{}
51+
4152 // approvalMsg asks the user to decide. reply is buffered, so answering
4253 // never blocks the UI even if the agent's context was cancelled first.
4354 approvalMsg struct {
@@ -77,6 +88,18 @@ func (b *bridge) Observe(ev agent.Event) {
7788 }
7889 }
7990
91+ // Subagent is the subagent.Options.Observe callback, called from a child's
92+ // own goroutine.
93+ func (b *bridge) Subagent(ev subagent.Event) { b.p.Send(evSub{ev: ev}) }
94+
95+ // AgentsChanged is the subagent.Options.Changed callback: something moved,
96+ // come and look.
97+ //
98+ // Send blocks until the update loop reads it, so this must never be reached
99+ // from inside Update — see controlSelected, which is why agent controls run
100+ // as commands.
101+ func (b *bridge) AgentsChanged() { b.p.Send(evAgents{}) }
102+
80103 // Prompt implements approval.Prompt. It hands the request to the UI and waits.
81104 //
82105 // The context is honoured while waiting, so ctrl-c during an approval prompt
+2−0internal/tui/commands.goView File
@@ -26,6 +26,7 @@ var builtins = []command{
2626 {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"},
2727 {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"},
2828 {name: "setup", desc: "the full-screen wizard for the saved defaults"},
29+ {name: "mouse", desc: "turn mouse reporting off, for selecting text with the terminal"},
2930 {name: "clear", desc: "empty the transcript"},
3031 {name: "allowed", desc: "what is permanently allowed this session"},
3132 {name: "exit", desc: "leave the harness"},
@@ -79,6 +80,7 @@ func (m *model) helpText() string {
7980 fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them")
8081 }
8182 b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" +
83+ "\n ctrl+b the agents sidebar — ↑↓ move · enter read one · p pause · k kill" +
8284 "\n ! runs a shell command · $ runs python — both on an empty prompt" +
8385 "\n what they print goes to the agent too, so you can then say \"fix that\"" +
8486 "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" +
+3−3internal/tui/complete.goView File
@@ -142,7 +142,7 @@ func (m *model) menuView() string {
142142 for _, c := range m.menu.items[start:end] {
143143 width = maxInt(width, lipgloss.Width("/"+c.name))
144144 }
145- width = minInt(width, maxInt(12, m.width/3))
145+ width = minInt(width, maxInt(12, m.mainWidth()/3))
146146
147147 var b strings.Builder
148148 for i := start; i < end; i++ {
@@ -158,10 +158,10 @@ func (m *model) menuView() string {
158158 } else {
159159 line = " " + styDim.Render(name) + " " + styFaint.Render(desc)
160160 }
161- b.WriteString(truncateStyled(" "+line, maxInt(1, m.width)) + "\n")
161+ b.WriteString(truncateStyled(" "+line, maxInt(1, m.mainWidth())) + "\n")
162162 }
163163 if n := len(m.menu.items) - (end - start); n > 0 {
164- b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.width)) + "\n")
164+ b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.mainWidth())) + "\n")
165165 }
166166 return strings.TrimRight(b.String(), "\n")
167167 }
+1−1internal/tui/effort.goView File
@@ -101,7 +101,7 @@ func (m *model) applyEffort(level string) {
101101 // ─────────────────────────────▲───────────────────────────────────
102102 // low medium high xhigh max
103103 func (m *model) effortView() string {
104- width := maxInt(1, m.width)
104+ width := maxInt(1, m.mainWidth())
105105
106106 starts := effortSlots(width - 1)
107107 if starts == nil {
+298−35internal/tui/model.goView File
@@ -19,6 +19,7 @@ import (
1919 "harness/internal/config"
2020 "harness/internal/llm"
2121 "harness/internal/skills"
22+ "harness/internal/subagent"
2223 )
2324
2425 // Options wires the TUI to an already-built agent. The TUI owns no policy of
@@ -46,6 +47,9 @@ type Options struct {
4647 // Skills are the instruction sets discovered at startup, used to resolve
4748 // /<slug> and to answer /skills.
4849 Skills []skills.Skill
50+ // Agents is the subagent supervisor, or nil where there is none: the
51+ // sidebar simply never opens, and no spawn tool was registered either.
52+ Agents Agents
4953
5054 // Config is what was loaded from disk, and FirstRun says there was nothing
5155 // to load — which is what opens the setup wizard unprompted.
@@ -133,9 +137,31 @@ type model struct {
133137 modelIdx int
134138 modelsLoading bool
135139
140+ // The agents sidebar: whether it is open, what it last drew, and which
141+ // agent's thread has taken over the main pane.
142+ bar sidebar
143+ viewing string
144+ // subTr is one scrollback per subagent, so a thread you open reads the
145+ // way your own does.
146+ subTr map[string]*transcript
147+ // pendingReports are finished agents whose reports have not gone up yet,
148+ // because a turn was running or the prompt had text in it.
149+ pendingReports []subagent.Info
150+ // mouseOn tracks whether mouse reporting is enabled, because turning it
151+ // on takes the terminal's own text selection away.
152+ mouseOn bool
153+ // pill is where the agents pill was last drawn, so a click on it can be
154+ // recognised.
155+ pill rowHit
156+
136157 // pending approvals. The agent blocks on one at a time, but a queue costs
137- // nothing and means a second request can never be dropped.
158+ // nothing and means a second request can never be dropped — and with
159+ // subagents there can genuinely be several, from different agents.
138160 pending []approvalMsg
161+ // beforeApproval is what the session was doing when the box went up. An
162+ // approval no longer implies a turn in flight: a subagent can ask while
163+ // you are idle, and answering has to put you back where you were.
164+ beforeApproval phase
139165
140166 // ctx plumbing for the running turn.
141167 parent context.Context
@@ -219,7 +245,10 @@ func newModel(ctx context.Context, opts Options) *model {
219245 // Run starts the program and blocks until the user exits.
220246 func Run(ctx context.Context, opts Options) error {
221247 m := newModel(ctx, opts)
222- p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx))
248+ // Mouse reporting buys the clickable agents pill and a scroll wheel, and
249+ // costs the terminal's own text selection. /mouse gives it back.
250+ m.mouseOn = true
251+ p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx), tea.WithMouseCellMotion())
223252
224253 // The bridge can only learn the program after it exists; the agent does
225254 // not touch it until the first turn starts, which is after Run.
@@ -228,6 +257,9 @@ func Run(ctx context.Context, opts Options) error {
228257 if opts.Policy != nil {
229258 opts.Policy.Prompt = m.bridge.Prompt
230259 }
260+ if opts.Agents != nil {
261+ opts.Agents.Subscribe(m.bridge.Subagent, m.bridge.AgentsChanged)
262+ }
231263
232264 final, err := p.Run()
233265 if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) {
@@ -310,6 +342,26 @@ func (m *model) closeWizard() tea.Cmd {
310342 return tea.Batch(m.sync(), textarea.Blink)
311343 }
312344
345+ // runContinue drives a turn on the conversation as it stands — the report a
346+ // subagent just filed is already in it. Everything else about the turn is the
347+ // same, including saving the session when it ends.
348+ func (m *model) runContinue() tea.Cmd {
349+ ctx, cancel := context.WithCancel(m.parent)
350+ m.cancel = cancel
351+
352+ a := m.opts.Agent
353+ return func() tea.Msg {
354+ _, err := a.Continue(ctx)
355+ cancel()
356+
357+ if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil && err == nil {
358+ err = fmt.Errorf("saving session: %w", saveErr)
359+ }
360+ u, turns := a.State().Usage()
361+ return turnDone{err: err, usage: u, turns: turns}
362+ }
363+ }
364+
313365 // runTurn drives one prompt on its own goroutine. Events reach the UI through
314366 // the bridge while this runs; the command's return value ends the turn.
315367 func (m *model) runTurn(prompt string) tea.Cmd {
@@ -397,10 +449,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
397449 return m, m.sync()
398450
399451 case approvalMsg:
452+ if m.phase != phaseApproving {
453+ m.beforeApproval = m.phase
454+ }
400455 m.pending = append(m.pending, msg)
401456 m.phase = phaseApproving
402457 return m, m.sync()
403458
459+ case evSub:
460+ return m, m.onSubEvent(msg.ev)
461+
462+ case evAgents:
463+ return m, m.onAgentsChanged()
464+
465+ case agentActed:
466+ return m, m.onAgentActed(msg)
467+
468+ case tea.MouseMsg:
469+ return m, m.onMouse(msg)
470+
404471 case turnDone:
405472 return m, m.finishTurn(msg)
406473
@@ -413,21 +480,64 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
413480
414481 func (m *model) resize(w, h int) tea.Cmd {
415482 m.width, m.height = w, h
416- m.tr.setWidth(w)
417- m.input.SetWidth(m.innerWidth())
418-
419483 if !m.ready {
420- m.vp = viewport.New(w, maxInt(1, h-8))
484+ m.vp = viewport.New(m.mainWidth(), maxInt(1, h-8))
421485 m.ready = true
422- } else {
423- m.vp.Width = w
424486 }
425- m.refreshSplash()
426- m.vp.SetContent(m.tr.render())
487+ m.relayout()
427488 m.vp.GotoBottom()
428489 return nil
429490 }
430491
492+ // relayout re-flows everything that is sized against the main column, which
493+ // is narrower whenever the sidebar is open.
494+ func (m *model) relayout() tea.Cmd {
495+ if !m.ready {
496+ return nil
497+ }
498+ w := m.mainWidth()
499+ m.tr.setWidth(w)
500+ for _, t := range m.subTr {
501+ t.setWidth(w)
502+ }
503+ m.input.SetWidth(m.innerWidth())
504+ m.vp.Width = w
505+ m.refreshSplash()
506+ return m.sync()
507+ }
508+
509+ // mainWidth is what is left for the transcript and the prompt.
510+ func (m *model) mainWidth() int {
511+ if m.bar.visible() && !m.fullScreenSidebar() {
512+ return maxInt(20, m.width-sidebarWidth)
513+ }
514+ return m.width
515+ }
516+
517+ // sidebarTakesOver reports whether the terminal is too narrow to show both.
518+ // Squeezing a transcript into thirty columns shows neither.
519+ func (m *model) fullScreenSidebar() bool {
520+ return m.bar.visible() && m.width-sidebarWidth < sidebarMinMain
521+ }
522+
523+ func (m *model) sidebarWidth() int {
524+ if m.fullScreenSidebar() {
525+ return m.width
526+ }
527+ return sidebarWidth
528+ }
529+
530+ // activeTranscript is what the main pane is showing: your own conversation, or
531+ // the thread of the agent you opened.
532+ func (m *model) activeTranscript() *transcript {
533+ if m.viewing != "" {
534+ if t, ok := m.subTr[m.viewing]; ok {
535+ return t
536+ }
537+ }
538+ return m.tr
539+ }
540+
431541 // refreshSplash re-renders the welcome block. It is the one entry that owns
432542 // its own lines — laid out for a width, and naming the model and the endpoint
433543 // — so when any of those change it is rebuilt rather than wrapped or left to
@@ -436,7 +546,7 @@ func (m *model) refreshSplash() {
436546 if len(m.tr.entries) == 0 || m.tr.entries[0].kind != entryRaw {
437547 return
438548 }
439- width := m.width
549+ width := m.mainWidth()
440550 if width <= 0 {
441551 width = 80
442552 }
@@ -451,7 +561,7 @@ func (m *model) sync() tea.Cmd {
451561 return nil
452562 }
453563 atBottom := m.vp.AtBottom()
454- m.vp.SetContent(m.tr.render())
564+ m.vp.SetContent(m.activeTranscript().render())
455565 if atBottom {
456566 m.vp.GotoBottom()
457567 }
@@ -465,11 +575,22 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
465575 m.addCost(subUsage(msg.usage, m.priced))
466576 m.tr.closeOpen()
467577
468- // Any approval still outstanding belongs to a turn that no longer exists.
578+ // An approval this turn raised belongs to a turn that no longer exists, so
579+ // it is denied. A subagent's does not: that agent is still running, and
580+ // the decision is still the user's to make.
581+ var kept []approvalMsg
469582 for _, p := range m.pending {
470- p.reply <- approval.Deny
583+ if p.req.Agent == "" {
584+ p.reply <- approval.Deny
585+ continue
586+ }
587+ kept = append(kept, p)
588+ }
589+ m.pending = kept
590+ if len(m.pending) > 0 {
591+ m.phase = phaseApproving
592+ m.beforeApproval = phaseIdle
471593 }
472- m.pending = nil
473594
474595 switch {
475596 case msg.err == nil:
@@ -478,8 +599,11 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
478599 default:
479600 m.tr.addError(msg.err.Error())
480601 }
481- m.input.Focus()
482- return tea.Batch(m.sync(), textarea.Blink)
602+ if !m.bar.hasKeys() {
603+ m.input.Focus()
604+ }
605+ // Agents that finished mid-turn have been waiting for this.
606+ return tea.Batch(m.sync(), textarea.Blink, m.deliverReports())
483607 }
484608
485609 func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -493,6 +617,15 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
493617 return m.onModelKey(msg)
494618 }
495619
620+ // The sidebar owns every key while it is open, the way the approval box
621+ // does: a pane with a kill key cannot share the keyboard with a prompt.
622+ if cmd, took := m.onSidebarKey(msg); took {
623+ return m, cmd
624+ }
625+ if msg.String() == "ctrl+b" && m.opts.Agents != nil {
626+ return m, m.toggleSidebar()
627+ }
628+
496629 // An open menu owns the keys that drive it — including enter and esc,
497630 // which mean something else the rest of the time.
498631 if m.menuOpen() {
@@ -572,7 +705,8 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
572705 m.input, cmd = m.input.Update(msg)
573706 m.growInput()
574707 m.refreshMenu()
575- return m, cmd
708+ // Emptying the prompt is what a report held back for it was waiting on.
709+ return m, tea.Batch(cmd, m.deliverReports())
576710 }
577711
578712 func (m *model) interrupt() tea.Cmd {
@@ -630,6 +764,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
630764 case line == "/effort" || strings.HasPrefix(line, "/effort "):
631765 return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))
632766
767+ case line == "/mouse":
768+ return m, m.toggleMouse()
769+
633770 case line == "/clear":
634771 m.tr.clear()
635772 return m, m.sync()
@@ -668,6 +805,11 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
668805 return m, m.sync()
669806 }
670807
808+ // Anything a subagent reported while you were typing goes up as its own
809+ // message, ahead of yours: the model should not have to work out which
810+ // half of one message was whose.
811+ m.flushReportsAhead()
812+
671813 m.tr.addUser(line)
672814 m.phase = phaseRunning
673815 m.turn = 0
@@ -753,18 +895,23 @@ func (m *model) choices(req approval.Request) []choice {
753895
754896 func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
755897 if len(m.pending) == 0 {
756- m.phase = phaseRunning
898+ m.phase = m.beforeApproval
757899 return m, nil
758900 }
759901 req := m.pending[0]
760902 key := msg.String()
761903
762- // ctrl-c mid-prompt denies the call and cancels the turn.
904+ // ctrl-c mid-prompt denies the call and cancels the turn it belonged to.
905+ // With nothing of yours running there is no turn to cancel — the denial
906+ // is the whole of it.
763907 if key == "ctrl+c" {
764908 req.reply <- approval.Deny
765909 m.pending = m.pending[1:]
766- m.phase = phaseRunning
767- return m, tea.Batch(m.interrupt(), m.sync())
910+ m.phase = m.phaseAfterApproval()
911+ if m.phase == phaseRunning {
912+ return m, tea.Batch(m.interrupt(), m.sync())
913+ }
914+ return m, m.sync()
768915 }
769916
770917 // "always this exact one" is not offered for an opaque command, and
@@ -782,7 +929,7 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
782929 req.reply <- c.dec
783930 m.pending = m.pending[1:]
784931 if len(m.pending) == 0 {
785- m.phase = phaseRunning
932+ m.phase = m.beforeApproval
786933 }
787934 // The decision belongs to the call it gated, not to whatever the
788935 // transcript happens to end with once the result lands.
@@ -792,6 +939,15 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
792939 return m, nil
793940 }
794941
942+ // phaseAfterApproval is where the session goes once the queue empties: back to
943+ // whatever it was doing, or straight on to the next request if one is waiting.
944+ func (m *model) phaseAfterApproval() phase {
945+ if len(m.pending) > 0 {
946+ return phaseApproving
947+ }
948+ return m.beforeApproval
949+ }
950+
795951 func matches(keys []string, key string) bool {
796952 for _, k := range keys {
797953 if k == key {
@@ -814,18 +970,36 @@ func (m *model) View() string {
814970 return m.wiz.View()
815971 }
816972
973+ // Too narrow for both: the pane takes the screen rather than leaving two
974+ // unreadable columns. The status line goes with the transcript, so the
975+ // pill it carries is not on screen and its click target is forgotten
976+ // rather than left pointing at whatever is drawn there now.
977+ if m.fullScreenSidebar() {
978+ m.pill = rowHit{}
979+ return m.sidebarView(m.height)
980+ }
981+
817982 // The bottom chrome changes height — the prompt box grows, the approval
818983 // box is taller than either — so the viewport is sized against what was
819984 // actually rendered rather than against an assumed number of rows.
820985 bottom := m.bottom()
821- if h := maxInt(1, m.height-lipgloss.Height(bottom)); h != m.vp.Height {
986+ top := ""
987+ if m.viewing != "" {
988+ top = m.agentHeader(m.mainWidth()) + "\n"
989+ }
990+ if h := maxInt(1, m.height-lipgloss.Height(bottom)-strings.Count(top, "\n")); h != m.vp.Height {
822991 atBottom := m.vp.AtBottom()
823992 m.vp.Height = h
824993 if atBottom {
825994 m.vp.GotoBottom()
826995 }
827996 }
828- return m.vp.View() + "\n" + bottom
997+
998+ main := top + m.vp.View() + "\n" + bottom
999+ if !m.bar.visible() {
1000+ return main
1001+ }
1002+ return lipgloss.JoinHorizontal(lipgloss.Top, main, m.sidebarView(m.height))
8291003 }
8301004
8311005 func (m *model) bottom() string {
@@ -855,7 +1029,7 @@ func (m *model) bottom() string {
8551029 // changes that is the one worth naming beside it.
8561030 func (m *model) activity() string {
8571031 if m.running != nil {
858- return spread(m.localActivity(), "", m.width)
1032+ return spread(m.localActivity(), "", m.mainWidth())
8591033 }
8601034
8611035 var left string
@@ -879,21 +1053,29 @@ func (m *model) activity() string {
8791053
8801054 right := ""
8811055 if m.opts.Effort != "" {
882- right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort")
1056+ // The pill is a claim about the next request. On a model that does
1057+ // not take this level the harness leaves it out rather than earning a
1058+ // 400, so the pill says which model is ignoring it instead of
1059+ // reporting a setting that is not in force.
1060+ note := " · /effort"
1061+ if !llm.SupportsEffort(m.opts.Client.Model(), m.opts.Effort) {
1062+ note = " · not on " + shortModel(m.opts.Client.Model())
1063+ }
1064+ right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(note)
8831065 }
884- return spread(left, right, m.width)
1066+ return spread(left, right, m.mainWidth())
8851067 }
8861068
8871069 func (m *model) innerWidth() int { return maxInt(8, m.boxWidth()-4) }
8881070
8891071 // boxWidth keeps the prompt frame usable on a narrow terminal rather than
8901072 // letting it collapse into a border with no room inside it.
891- func (m *model) boxWidth() int { return maxInt(14, m.width) }
1073+ func (m *model) boxWidth() int { return maxInt(14, m.mainWidth()) }
8921074
8931075 func (m *model) inputBox() string {
8941076 style := styBoxFocus
8951077 switch {
896- case m.phase == phaseRunning:
1078+ case m.phase == phaseRunning, m.bar.hasKeys():
8971079 style = styBoxBlur
8981080 case m.local != localOff:
8991081 // The frame is the always-present chrome, so it is where a mode that
@@ -917,6 +1099,10 @@ func (m *model) status() string {
9171099 parts = append(parts, pill)
9181100 }
9191101
1102+ if pill, ok := m.agentsPill(); ok {
1103+ parts = append(parts, pill)
1104+ }
1105+
9201106 // Cache reads are reported separately from fresh input: the gap between
9211107 // the two is the whole point of the caching this harness measures.
9221108 u := m.usage
@@ -931,7 +1117,45 @@ func (m *model) status() string {
9311117 parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost)))
9321118 }
9331119 }
934- return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width))
1120+ line := strings.Join(parts, " ")
1121+ m.recordPill(parts)
1122+ return truncateStyled(line, maxInt(1, m.mainWidth()))
1123+ }
1124+
1125+ // agentsPill is the clickable button in the status line: how many agents are
1126+ // live, or just the way in when none are.
1127+ func (m *model) agentsPill() (string, bool) {
1128+ if m.opts.Agents == nil {
1129+ return "", false
1130+ }
1131+ label := "⏵ agents"
1132+ style := styPill
1133+ if n := m.bar.live; n > 0 {
1134+ label = fmt.Sprintf("⏵ %d agents", n)
1135+ style = styPill.Foreground(colCoral)
1136+ } else if len(m.bar.rows) == 0 {
1137+ return "", false
1138+ }
1139+ return style.Render(label), true
1140+ }
1141+
1142+ // recordPill remembers where the agents pill was drawn, so a click on it can
1143+ // be told from a click on the rest of the status line. The status line is the
1144+ // second line from the bottom: the mode line is below it.
1145+ func (m *model) recordPill(parts []string) {
1146+ m.pill = rowHit{}
1147+ pill, ok := m.agentsPill()
1148+ if !ok {
1149+ return
1150+ }
1151+ var x int
1152+ for _, p := range parts {
1153+ if p == pill {
1154+ break
1155+ }
1156+ x += lipgloss.Width(p) + 2
1157+ }
1158+ m.pill = rowHit{y0: m.height - 2, y1: m.height - 2, x0: x, x1: x + lipgloss.Width(pill) - 1}
9351159 }
9361160
9371161 // contextPill is the context gauge: how much the model read last turn against
@@ -968,19 +1192,23 @@ func (m *model) modeLine() string {
9681192 if m.phase == phaseApproving {
9691193 return ""
9701194 }
1195+ agents := ""
1196+ if m.opts.Agents != nil {
1197+ agents = " · ctrl+b agents"
1198+ }
9711199 if m.opts.Policy != nil && m.opts.Policy.YOLO {
972- return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"), maxInt(1, m.width))
1200+ return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"+agents), maxInt(1, m.mainWidth()))
9731201 }
974- return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle) · /help for commands"), maxInt(1, m.width))
1202+ return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle)"+agents+" · /help"), maxInt(1, m.mainWidth()))
9751203 }
9761204
9771205 func (m *model) approvalView() string {
9781206 req := m.pending[0].req
979- width := maxInt(20, m.width-2)
1207+ width := maxInt(20, m.mainWidth()-2)
9801208 inner := width - 4
9811209
9821210 var b strings.Builder
983- b.WriteString(styHeading.Render(req.Tool) + styDim.Render(" wants to "+req.Summary) + "\n")
1211+ b.WriteString(m.approvalHead(req) + styDim.Render(" wants to "+req.Summary) + "\n")
9841212 if req.Detail != "" {
9851213 b.WriteString("\n")
9861214 lines := strings.Split(strings.TrimRight(req.Detail, "\n"), "\n")
@@ -1002,6 +1230,21 @@ func (m *model) approvalView() string {
10021230 return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n"))
10031231 }
10041232
1233+ // approvalHead is the first thing the box says: the tool, and — when it is not
1234+ // the session's own agent asking — which subagent wants it. Four agents can be
1235+ // running, and "bash wants to run rm -rf build" is not a question anybody can
1236+ // answer without knowing whose it is.
1237+ func (m *model) approvalHead(req approval.Request) string {
1238+ if req.Agent == "" {
1239+ return styHeading.Render(req.Tool)
1240+ }
1241+ who := req.Agent
1242+ if info, ok := m.agentInfo(req.Agent); ok {
1243+ who += " (" + info.Preset + ")"
1244+ }
1245+ return styCoral.Render(who) + styDim.Render(" · ") + styHeading.Render(req.Tool)
1246+ }
1247+
10051248 // spread puts left at the left margin and right at the right, and gives up on
10061249 // the right-hand side rather than overflowing when they cannot both fit.
10071250 func spread(left, right string, width int) string {
@@ -1024,6 +1267,26 @@ func username() string {
10241267
10251268 // addCost prices one piece of usage at the model in force and adds it to the
10261269 // running total, recording that those tokens have now been paid for.
1270+ // addCostAt bills a subagent's usage to the session at that agent's own model:
1271+ // a haiku agent spawned from an opus session was not billed at opus rates.
1272+ //
1273+ // Only the money is pooled. The token counts in the status line stay the main
1274+ // conversation's, because the gauge beside them is about the window this
1275+ // conversation is filling — a child's tokens went into a different window, and
1276+ // the sidebar reports them per agent.
1277+ func (m *model) addCostAt(u llm.Usage, model string) {
1278+ if u == (llm.Usage{}) {
1279+ return
1280+ }
1281+ if model == "" {
1282+ model = m.opts.Client.Model()
1283+ }
1284+ if c, known := u.Cost(model); known {
1285+ m.cost += c
1286+ m.costKnown = true
1287+ }
1288+ }
1289+
10271290 func (m *model) addCost(u llm.Usage) {
10281291 if u == (llm.Usage{}) {
10291292 return
+1−1internal/tui/model_picker.goView File
@@ -141,7 +141,7 @@ func (m *model) applyModel(id string) {
141141 // modelView is the picker: a heading, where the list came from, and a row per
142142 // model with what it is called, its id, and what it costs.
143143 func (m *model) modelView() string {
144- width := maxInt(1, m.width)
144+ width := maxInt(1, m.mainWidth())
145145 head := " " + styHeading.Render("Select model")
146146 from := styDim.Render("from " + endpointLabel(m.opts.Client))
147147
+9−0internal/tui/model_test.goView File
@@ -85,6 +85,10 @@ func TestApprovalKeysMapToDecisions(t *testing.T) {
8585 for _, tc := range cases {
8686 t.Run(tc.key, func(t *testing.T) {
8787 m := newTestModel(t)
88+ // The session's own agent asks during a turn; answering returns
89+ // to it. (A subagent can ask while you are idle — see
90+ // TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle.)
91+ m.phase = phaseRunning
8892 reply := pend(m, approval.Request{Tool: "bash", Rememberable: true})
8993 if m.phase != phaseApproving {
9094 t.Fatalf("phase = %v, want phaseApproving", m.phase)
@@ -153,6 +157,10 @@ func TestUnknownKeyDuringApprovalDoesNothing(t *testing.T) {
153157 // Queued approvals are answered one at a time, in order.
154158 func TestApprovalsAreQueued(t *testing.T) {
155159 m := newTestModel(t)
160+ // These are the session's own agent asking, which only happens during a
161+ // turn. A subagent can ask while you are idle, and then answering puts
162+ // you back to idle rather than into a turn that is not running.
163+ m.phase = phaseRunning
156164 first := pend(m, approval.Request{Tool: "bash", Rememberable: true})
157165 second := pend(m, approval.Request{Tool: "edit_file", Rememberable: true})
158166
@@ -200,6 +208,7 @@ func TestTurnDoneDeniesOutstandingApprovals(t *testing.T) {
200208
201209 func TestCtrlCDuringApprovalDeniesAndInterrupts(t *testing.T) {
202210 m := newTestModel(t)
211+ m.phase = phaseRunning
203212 cancelled := false
204213 m.cancel = func() { cancelled = true }
205214 reply := pend(m, approval.Request{Tool: "bash"})
+727−0internal/tui/sidebar.goaddedView File
@@ -0,0 +1,727 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/charmbracelet/bubbles/textarea"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+
12+ "harness/internal/agent"
13+ "harness/internal/llm"
14+ "harness/internal/subagent"
15+ )
16+
17+ // Agents is the part of the subagent supervisor the TUI uses: what is running,
18+ // what has finished and not been handed over yet, and the three things a
19+ // person can do to one. It is an interface so the front end can be driven
20+ // without starting real agents.
21+ type Agents interface {
22+ Snapshot() []subagent.Info
23+ TakeReports() []subagent.Info
24+ Live() int
25+ Pause(id string) error
26+ Resume(id string) error
27+ Kill(id string) error
28+ // ClaimReport asks for the right to put one finished agent's report into
29+ // the conversation, and reports whether this front end got it. It can be
30+ // refused: agent_wait may have handed the same report to the model while
31+ // this one sat in the pending queue.
32+ ClaimReport(id string) bool
33+ // Subscribe hands the supervisor the two callbacks the front end wants:
34+ // one per child event, one when anything about the set of agents moves.
35+ Subscribe(observe func(subagent.Event), changed func())
36+ }
37+
38+ // sidebarWidth is the pane's width when there is room for it. Thirty columns
39+ // fits "claude-haiku-4-5 · 22s · 3 turns" and not much else, which is exactly
40+ // what a row has to say.
41+ const sidebarWidth = 30
42+
43+ // barFrame is what the pane's own chrome costs: one column of rule on the
44+ // left and a column of padding either side.
45+ const barFrame = 3
46+
47+ // sidebarMinMain is how much has to be left for the transcript. Below this the
48+ // sidebar takes the whole screen instead of squeezing both into unreadability.
49+ const sidebarMinMain = 40
50+
51+ // sidebar is the agents pane: whether it is open, whether it has the keyboard,
52+ // where the cursor is, and the rows it last drew.
53+ //
54+ // Open and focused are separate because the pane is worth watching while you
55+ // type. It owns every key while it has focus — that is what makes k a kill
56+ // rather than a letter — so there has to be a way to put the keyboard back in
57+ // the prompt without closing what you were looking at.
58+ type sidebar struct {
59+ open bool
60+ focused bool
61+ cursor int
62+ rows []subagent.Info
63+ live int
64+ // hits records where each row was drawn, so a click can be turned back
65+ // into an agent. It is rebuilt on every render.
66+ hits []rowHit
67+ }
68+
69+ // rowHit is where something clickable was last drawn.
70+ type rowHit struct {
71+ y0, y1 int
72+ x0, x1 int
73+ id string
74+ }
75+
76+ // hit reports whether a click at (x, y) landed on this region.
77+ func (h rowHit) hit(x, y int) bool {
78+ if y < h.y0 || y > h.y1 {
79+ return false
80+ }
81+ return (h.x0 == 0 && h.x1 == 0) || (x >= h.x0 && x <= h.x1)
82+ }
83+
84+ // visible reports whether the pane is drawn at all.
85+ func (s *sidebar) visible() bool { return s.open }
86+
87+ // hasKeys reports whether the pane is the thing keystrokes belong to.
88+ func (s *sidebar) hasKeys() bool { return s.open && s.focused }
89+
90+ // selected is the agent the cursor is on.
91+ func (s *sidebar) selected() (subagent.Info, bool) {
92+ if s.cursor < 0 || s.cursor >= len(s.rows) {
93+ return subagent.Info{}, false
94+ }
95+ return s.rows[s.cursor], true
96+ }
97+
98+ func (s *sidebar) move(delta int) {
99+ if len(s.rows) == 0 {
100+ return
101+ }
102+ s.cursor += delta
103+ switch {
104+ case s.cursor < 0:
105+ s.cursor = 0
106+ case s.cursor >= len(s.rows):
107+ s.cursor = len(s.rows) - 1
108+ }
109+ }
110+
111+ // selectID puts the cursor on an agent by id.
112+ func (s *sidebar) selectID(id string) {
113+ for i, r := range s.rows {
114+ if r.ID == id {
115+ s.cursor = i
116+ return
117+ }
118+ }
119+ }
120+
121+ // refresh pulls the current rows. The cursor follows the agent it was on
122+ // rather than the index, so a row appearing above it does not move the
123+ // selection out from under a keystroke.
124+ func (m *model) refreshAgents() {
125+ if m.opts.Agents == nil {
126+ return
127+ }
128+ var on string
129+ if cur, ok := m.bar.selected(); ok {
130+ on = cur.ID
131+ }
132+ m.bar.rows = m.opts.Agents.Snapshot()
133+ m.bar.live = m.opts.Agents.Live()
134+ if on != "" {
135+ m.bar.selectID(on)
136+ }
137+ if m.bar.cursor >= len(m.bar.rows) {
138+ m.bar.cursor = maxInt(0, len(m.bar.rows)-1)
139+ }
140+ }
141+
142+ // toggleSidebar is what ctrl+b does, and it always moves towards the pane:
143+ // closed opens it, open-but-unfocused hands it the keyboard, and only a pane
144+ // that already has the keyboard is closed. Closing what you are looking at is
145+ // never what someone reaching for this key meant.
146+ func (m *model) toggleSidebar() tea.Cmd {
147+ switch {
148+ case !m.bar.open:
149+ m.bar.open = true
150+ m.refreshAgents()
151+ return m.focusSidebar()
152+ case !m.bar.focused:
153+ return m.focusSidebar()
154+ default:
155+ m.bar.open, m.bar.focused = false, false
156+ m.viewing = ""
157+ m.input.Focus()
158+ return tea.Batch(m.relayout(), textarea.Blink)
159+ }
160+ }
161+
162+ // focusSidebar gives the pane the keyboard. The prompt is blurred, so the
163+ // frame says where the next keystroke is going.
164+ func (m *model) focusSidebar() tea.Cmd {
165+ m.bar.focused = true
166+ m.input.Blur()
167+ return tea.Batch(m.relayout(), textarea.Blink)
168+ }
169+
170+ // focusPrompt puts the keyboard back in the prompt, leaving the pane on screen
171+ // to watch while you type.
172+ func (m *model) focusPrompt() tea.Cmd {
173+ if !m.bar.focused {
174+ return nil
175+ }
176+ m.bar.focused = false
177+ m.input.Focus()
178+ return tea.Batch(m.sync(), textarea.Blink)
179+ }
180+
181+ // onSidebarKey handles the keys while the pane has them, and reports whether
182+ // it took the key.
183+ func (m *model) onSidebarKey(msg tea.KeyMsg) (tea.Cmd, bool) {
184+ if !m.bar.hasKeys() {
185+ return nil, false
186+ }
187+ switch msg.String() {
188+ case "ctrl+c", "ctrl+d":
189+ // The keys that stop things are never the pane's. Opening it to watch
190+ // an agent must not cost you the ability to interrupt your own turn or
191+ // to leave.
192+ return nil, false
193+
194+ case "ctrl+b":
195+ return m.toggleSidebar(), true
196+
197+ case "esc":
198+ // One esc leaves the agent's thread, the next closes the pane: the
199+ // same key walks back out the way you came in.
200+ if m.viewing != "" {
201+ m.viewing = ""
202+ return m.sync(), true
203+ }
204+ return m.toggleSidebar(), true
205+
206+ case "up":
207+ m.bar.move(-1)
208+ return m.followSelection(), true
209+
210+ case "down":
211+ m.bar.move(1)
212+ return m.followSelection(), true
213+
214+ case "enter":
215+ if cur, ok := m.bar.selected(); ok {
216+ m.viewing = cur.ID
217+ return m.sync(), true
218+ }
219+ return nil, true
220+
221+ case "p":
222+ return m.controlSelected("p"), true
223+
224+ case "k":
225+ return m.controlSelected("k"), true
226+ }
227+
228+ // Everything else is swallowed: the pane has the keys while it is open, so
229+ // a stray letter cannot end up in a prompt nobody is looking at.
230+ return nil, true
231+ }
232+
233+ // followSelection keeps the thread on screen in step with the cursor, so
234+ // moving down the list while reading walks through the threads.
235+ func (m *model) followSelection() tea.Cmd {
236+ if m.viewing != "" {
237+ if cur, ok := m.bar.selected(); ok {
238+ m.viewing = cur.ID
239+ }
240+ }
241+ return m.sync()
242+ }
243+
244+ // controlSelected applies p (pause or resume, whichever the row is not) or k
245+ // (kill) to the agent under the cursor.
246+ //
247+ // The supervisor is called from the returned command rather than from here,
248+ // and that is not a style choice: acting on an agent makes the supervisor
249+ // announce the change, which it does by sending this program a message — and
250+ // the channel that carries it is read by the update loop we would still be
251+ // inside. Calling it here deadlocks the whole program, keyboard included.
252+ func (m *model) controlSelected(key string) tea.Cmd {
253+ cur, ok := m.bar.selected()
254+ if !ok || m.opts.Agents == nil {
255+ return nil
256+ }
257+ agents, id := m.opts.Agents, cur.ID
258+ paused := cur.Status == subagent.Paused
259+
260+ return func() tea.Msg {
261+ switch {
262+ case key == "k":
263+ return agentActed{err: agents.Kill(id)}
264+ case paused:
265+ return agentActed{err: agents.Resume(id)}
266+ default:
267+ return agentActed{err: agents.Pause(id)}
268+ }
269+ }
270+ }
271+
272+ // onAgentActed reports a refusal — an agent that has already finished, or one
273+ // that has not started. The change itself arrives separately, as the
274+ // supervisor's own notification.
275+ func (m *model) onAgentActed(msg agentActed) tea.Cmd {
276+ if msg.err == nil {
277+ return nil
278+ }
279+ m.tr.addError(msg.err.Error())
280+ return m.sync()
281+ }
282+
283+ // sidebarView draws the pane at the given height.
284+ func (m *model) sidebarView(height int) string {
285+ width := m.sidebarWidth()
286+ inner := maxInt(8, width-barFrame)
287+
288+ var b strings.Builder
289+ // The heading carries the accent only while the pane has the keyboard,
290+ // which is the same grammar the prompt frame uses.
291+ head := styDim.Render("Agents")
292+ if m.bar.focused {
293+ head = styHeading.Render("Agents")
294+ }
295+ count := styFaint.Render("none yet")
296+ if n := m.bar.live; n > 0 {
297+ count = styCoral.Render(fmt.Sprintf("%d ⏵", n))
298+ } else if len(m.bar.rows) > 0 {
299+ count = styFaint.Render(fmt.Sprintf("%d done", len(m.bar.rows)))
300+ }
301+ b.WriteString(spread(head, count, inner) + "\n\n")
302+
303+ m.bar.hits = nil
304+ // The pane has no top border, so its first line is the screen's first
305+ // line: the header and the blank line under it are all that come before
306+ // the rows.
307+ y := 2
308+ if len(m.bar.rows) == 0 {
309+ b.WriteString(styFaint.Render(wrapTo("nothing running. The agent starts these itself, with your say-so.", inner)) + "\n")
310+ }
311+ for i, r := range m.bar.rows {
312+ lines := m.agentRow(r, i == m.bar.cursor, inner)
313+ b.WriteString(lines + "\n")
314+ h := lipgloss.Height(lines)
315+ m.bar.hits = append(m.bar.hits, rowHit{y0: y, y1: y + h - 1, id: r.ID})
316+ y += h
317+ }
318+
319+ if m.bar.focused {
320+ b.WriteString("\n" + styFaint.Render(truncate("↑↓ move · enter open", inner)) +
321+ "\n" + styFaint.Render(truncate("p pause · k kill · esc back", inner)))
322+ } else {
323+ b.WriteString("\n" + styFaint.Render(truncate("ctrl+b or click here", inner)) +
324+ "\n" + styFaint.Render(truncate("for these agents' keys", inner)))
325+ }
326+
327+ // Width is the block inside the border, padding included, so the rule
328+ // itself is the one column not accounted for here.
329+ return styBar.Width(width - 1).Height(maxInt(1, height)).Render(strings.TrimRight(b.String(), "\n"))
330+ }
331+
332+ // agentRow is one agent: what it is on the first line, how it is going on the
333+ // second. Two lines because one cannot hold both without abbreviating the
334+ // numbers into uselessness.
335+ func (m *model) agentRow(r subagent.Info, selected bool, width int) string {
336+ glyph, tone := statusLook(r.Status)
337+
338+ marker := " "
339+ name := styText.Render(r.ID + " " + r.Preset)
340+ if selected {
341+ marker = styCoral.Render("❯ ")
342+ name = styBold.Foreground(colText).Render(r.ID + " " + r.Preset)
343+ }
344+ if m.viewing == r.ID {
345+ name += styCoral.Render(" ◂")
346+ }
347+ first := marker + tone.Render(glyph) + " " + name
348+ // The cost goes at the right of the first line rather than at the end of
349+ // the second: thirty columns cannot hold model, elapsed, turns and money
350+ // on one line, and the number you look for is the money.
351+ // Below a cent, two decimal places say "$0.00", which reads as free. The
352+ // exact figure is in the line the transcript gets when the agent ends.
353+ if cost, ok := r.Cost(); ok && cost >= 0.005 {
354+ first = spread(first, styFaint.Render(fmt.Sprintf("$%.2f", cost)), width)
355+ }
356+
357+ var parts []string
358+ if r.Status == subagent.Queued {
359+ parts = append(parts, "queued")
360+ } else {
361+ parts = append(parts, shortModel(r.Model))
362+ }
363+ switch r.Status {
364+ case subagent.Waiting:
365+ parts = append(parts, "waiting")
366+ case subagent.Paused:
367+ parts = append(parts, "paused")
368+ case subagent.Failed:
369+ parts = append(parts, "failed")
370+ case subagent.Killed:
371+ parts = append(parts, "stopped")
372+ case subagent.Done:
373+ parts = append(parts, "done")
374+ default:
375+ if d := r.Elapsed(); d > 0 {
376+ parts = append(parts, compactDuration(d))
377+ }
378+ }
379+ if r.Turns > 0 {
380+ parts = append(parts, plural(r.Turns, "turn"))
381+ }
382+ second := " " + styFaint.Render(truncate(strings.Join(parts, " · "), maxInt(6, width-2)))
383+
384+ return truncateStyled(first, width) + "\n" + second
385+ }
386+
387+ // shortModel is the family alone — "Haiku", "Opus" — which is all a
388+ // thirty-column row has space for. The full id is in the thread's header, one
389+ // keystroke away.
390+ func shortModel(id string) string {
391+ pretty := prettyModel(id)
392+ if pretty != id {
393+ if i := strings.IndexByte(pretty, ' '); i > 0 {
394+ return pretty[:i]
395+ }
396+ }
397+ return pretty
398+ }
399+
400+ // compactDuration is how long an agent has been at it. Past a minute, a count
401+ // of seconds stops reading as a duration.
402+ func compactDuration(d time.Duration) string {
403+ if d < time.Minute {
404+ return fmt.Sprintf("%ds", int(d.Seconds()))
405+ }
406+ return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
407+ }
408+
409+ // statusLook is the glyph and colour one status gets. The running glyph is the
410+ // same star the status line pulses with, so a live agent looks live.
411+ func statusLook(s subagent.Status) (string, lipgloss.Style) {
412+ switch s {
413+ case subagent.Queued:
414+ return "◦", styFaint
415+ case subagent.Running:
416+ return "✳", styCoral
417+ case subagent.Waiting:
418+ return "●", styWarn
419+ case subagent.Paused:
420+ return "‖", styDim
421+ case subagent.Done:
422+ return "✓", styOK
423+ case subagent.Failed:
424+ return "✗", styErr
425+ case subagent.Killed:
426+ return "✗", styDim
427+ }
428+ return "·", styDim
429+ }
430+
431+ // agentHeader is the bar above a subagent's thread, so a transcript that looks
432+ // like yours cannot be mistaken for yours.
433+ func (m *model) agentHeader(width int) string {
434+ var r subagent.Info
435+ var found bool
436+ for _, row := range m.bar.rows {
437+ if row.ID == m.viewing {
438+ r, found = row, true
439+ break
440+ }
441+ }
442+ if !found {
443+ return ""
444+ }
445+ glyph, tone := statusLook(r.Status)
446+ detail := " · " + prettyModel(r.Model)
447+ if r.Effort != "" {
448+ detail += " · " + r.Effort + " effort"
449+ }
450+ detail += " · " + string(r.Status)
451+ left := tone.Render(glyph) + " " + styHeading.Render(r.ID+" · "+r.Preset) + styDim.Render(detail)
452+ right := styFaint.Render("esc back")
453+ return spread(left, right, maxInt(1, width))
454+ }
455+
456+ // subTranscript is the scrollback for one subagent, created on first sight.
457+ func (m *model) subTranscript(id string) *transcript {
458+ if m.subTr == nil {
459+ m.subTr = map[string]*transcript{}
460+ }
461+ t, ok := m.subTr[id]
462+ if !ok {
463+ t = newTranscript(m.mainWidth())
464+ t.setVerbose(m.opts.Verbose)
465+ m.subTr[id] = t
466+ if info, found := m.agentInfo(id); found {
467+ t.addUser(info.Task)
468+ }
469+ }
470+ return t
471+ }
472+
473+ func (m *model) agentInfo(id string) (subagent.Info, bool) {
474+ for _, r := range m.bar.rows {
475+ if r.ID == id {
476+ return r, true
477+ }
478+ }
479+ return subagent.Info{}, false
480+ }
481+
482+ // onSubEvent routes one subagent's event into that agent's transcript. It is
483+ // the same handling the main thread gets, against a different transcript:
484+ // tool calls, diffs and elision all behave identically.
485+ func (m *model) onSubEvent(ev subagent.Event) tea.Cmd {
486+ t := m.subTranscript(ev.ID)
487+ switch ev.Agent.Kind {
488+ case agent.EventTextDelta:
489+ t.appendDelta(ev.Agent.Text)
490+ case agent.EventText:
491+ t.add(entry{kind: entryAssistant, text: strings.TrimSpace(ev.Agent.Text)})
492+ case agent.EventToolStart:
493+ t.startTool(ev.Agent.Tool, summarizeInput(ev.Agent.Input))
494+ case agent.EventToolResult:
495+ t.finishTool(ev.Agent.Tool, ev.Agent.Result, ev.Agent.IsError)
496+ case agent.EventCompact:
497+ t.addNotice("compacted the conversation")
498+ case agent.EventPaused:
499+ t.addNotice("paused")
500+ case agent.EventResumed:
501+ t.addNotice("resumed")
502+ case agent.EventUsage:
503+ // A child's tokens are the session's tokens: they are spent on the
504+ // same key, and priced at the child's own model rather than at
505+ // whatever the session happens to be on.
506+ m.addCostAt(ev.Agent.Usage, ev.Model)
507+ return nil
508+ default:
509+ return nil
510+ }
511+ if m.viewing == ev.ID {
512+ return m.sync()
513+ }
514+ return nil
515+ }
516+
517+ // onAgentsChanged is the supervisor telling us something moved: a status, or a
518+ // finished agent with a report to hand over.
519+ func (m *model) onAgentsChanged() tea.Cmd {
520+ if m.opts.Agents == nil {
521+ return nil
522+ }
523+ m.refreshAgents()
524+
525+ for _, r := range m.opts.Agents.TakeReports() {
526+ // Queued, not delivered: whether this one is ours to hand over is
527+ // decided when there is a turn to put it in, because agent_wait can
528+ // take it in the meantime.
529+ m.pendingReports = append(m.pendingReports, r)
530+ m.tr.addNotice(agentFinishedLine(r))
531+ }
532+ return tea.Batch(m.sync(), m.deliverReports())
533+ }
534+
535+ // agentFinishedLine is the one line your own transcript gets when an agent
536+ // ends. The report itself goes to the model; this is for you.
537+ func agentFinishedLine(r subagent.Info) string {
538+ var b strings.Builder
539+ fmt.Fprintf(&b, "%s (%s) %s", r.ID, r.Preset, endedWord(r.Status))
540+ var parts []string
541+ if r.Turns > 0 {
542+ parts = append(parts, plural(r.Turns, "turn"))
543+ }
544+ if d := r.Elapsed(); d > 0 {
545+ parts = append(parts, fmt.Sprintf("%ds", int(d.Seconds())))
546+ }
547+ if cost, ok := r.Cost(); ok && cost > 0 {
548+ parts = append(parts, fmt.Sprintf("$%.4f", cost))
549+ }
550+ if len(parts) > 0 {
551+ fmt.Fprintf(&b, " — %s", strings.Join(parts, " · "))
552+ }
553+ if r.Status == subagent.Failed && r.Err != "" {
554+ fmt.Fprintf(&b, ": %s", r.Err)
555+ }
556+ b.WriteString(" · ctrl+b to read it")
557+ return b.String()
558+ }
559+
560+ func endedWord(s subagent.Status) string {
561+ switch s {
562+ case subagent.Failed:
563+ return "failed"
564+ case subagent.Killed:
565+ return "was stopped"
566+ }
567+ return "finished"
568+ }
569+
570+ // readyForReports reports whether a report can go up right now. Everything it
571+ // refuses on is a case where starting a turn would take the screen away from
572+ // something the user is in the middle of — most of all a prompt with text in
573+ // it, which a starting turn would blur under their fingers.
574+ func (m *model) readyForReports() bool {
575+ return m.opts.Agent != nil &&
576+ m.phase == phaseIdle &&
577+ m.wiz == nil &&
578+ m.running == nil &&
579+ len(m.pending) == 0 &&
580+ m.local == localOff &&
581+ strings.TrimSpace(m.input.Value()) == ""
582+ }
583+
584+ // deliverReports gives the agent everything that has finished, and sets it
585+ // going. This is the half of the feature that makes spawning worth doing: you
586+ // do not have to come back and ask how the agents got on.
587+ func (m *model) deliverReports() tea.Cmd {
588+ if len(m.pendingReports) == 0 || !m.readyForReports() {
589+ return nil
590+ }
591+ // Every pending report may have been collected by agent_wait since it was
592+ // queued, in which case there is nothing to say and no turn to start.
593+ if !m.flushReportsAhead() {
594+ return nil
595+ }
596+ m.phase = phaseRunning
597+ m.started = time.Now()
598+ m.turn = 0
599+ m.verbSeed++
600+ // The prompt keeps focus, exactly as it does for a turn you started
601+ // yourself: typing ahead while the agent works is allowed, and this turn
602+ // is not special for having started on its own.
603+ return tea.Batch(m.runContinue(), m.spin.Tick, m.sync())
604+ }
605+
606+ // takeReportText folds the reports this front end is still entitled to hand
607+ // over into one message, and clears the queue.
608+ //
609+ // The claim is made here rather than when the report was drained, because the
610+ // gap between the two can be minutes: the parent's own agent_wait may have
611+ // collected the report in that time, and putting it in again would charge the
612+ // model twice for something already in its context.
613+ func (m *model) takeReportText() string {
614+ var parts []string
615+ for _, r := range m.pendingReports {
616+ if m.opts.Agents != nil && !m.opts.Agents.ClaimReport(r.ID) {
617+ continue
618+ }
619+ parts = append(parts, r.Notification())
620+ }
621+ m.pendingReports = nil
622+ return strings.Join(parts, "\n\n")
623+ }
624+
625+ // flushReportsAhead puts pending reports into the conversation as their own
626+ // message, ahead of whatever the user is about to send. Gluing the two
627+ // together would leave the model unable to tell which words were whose.
628+ //
629+ // It reports whether anything was actually added: every pending report may
630+ // turn out to belong to agent_wait instead.
631+ func (m *model) flushReportsAhead() bool {
632+ if len(m.pendingReports) == 0 || m.opts.Agent == nil {
633+ return false
634+ }
635+ text := m.takeReportText()
636+ if text == "" {
637+ return false
638+ }
639+ m.opts.Agent.State().Append(llm.Message{
640+ Role: llm.RoleUser,
641+ Content: []llm.ContentBlock{llm.TextBlock(text)},
642+ })
643+ return true
644+ }
645+
646+ // onMouse routes a click or a wheel. The wheel scrolls whatever pane it is
647+ // over; a click on the agents pill toggles the sidebar, and a click on a row
648+ // selects it — a second click on the row already selected opens its thread,
649+ // which is the double-click anyone tries first.
650+ func (m *model) onMouse(msg tea.MouseMsg) tea.Cmd {
651+ if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
652+ if m.bar.visible() && msg.X >= m.width-m.sidebarWidth() {
653+ // The list is short enough to fit; scrolling it would be a scroll
654+ // bar with nothing behind it.
655+ return nil
656+ }
657+ var cmd tea.Cmd
658+ m.vp, cmd = m.vp.Update(msg)
659+ return cmd
660+ }
661+ if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
662+ return nil
663+ }
664+
665+ if m.opts.Agents != nil && m.pill.hit(msg.X, msg.Y) {
666+ return m.toggleSidebar()
667+ }
668+ if !m.bar.visible() {
669+ return nil
670+ }
671+ // A click is also a statement about where you are working: in the
672+ // conversation, or in the pane.
673+ if msg.X < m.width-m.sidebarWidth() {
674+ return m.focusPrompt()
675+ }
676+ cmds := []tea.Cmd{m.focusSidebar()}
677+ for i, h := range m.bar.hits {
678+ if !h.hit(msg.X, msg.Y) {
679+ continue
680+ }
681+ if m.bar.cursor == i && m.viewing != h.id {
682+ m.viewing = h.id
683+ } else {
684+ m.bar.cursor = i
685+ }
686+ return tea.Batch(append(cmds, m.followSelection())...)
687+ }
688+ return tea.Batch(cmds...)
689+ }
690+
691+ // toggleMouse answers /mouse. Reporting is on by default because it buys the
692+ // clickable pill and the wheel; it is turnable off because it takes the
693+ // terminal's own text selection with it, and copying a stack trace out of the
694+ // transcript matters more than clicking.
695+ func (m *model) toggleMouse() tea.Cmd {
696+ m.mouseOn = !m.mouseOn
697+ if m.mouseOn {
698+ m.tr.addNotice("mouse on — clicks and the wheel work; your terminal's own text selection may need shift held down")
699+ return tea.Batch(m.sync(), tea.EnableMouseCellMotion)
700+ }
701+ m.tr.addNotice("mouse off — select and copy text as usual; /mouse turns it back on")
702+ return tea.Batch(m.sync(), tea.DisableMouse)
703+ }
704+
705+ // wrapTo is a minimal hard wrap for the pane's own hint text.
706+ func wrapTo(s string, width int) string {
707+ if width <= 0 {
708+ return s
709+ }
710+ var lines []string
711+ line := ""
712+ for _, word := range strings.Fields(s) {
713+ switch {
714+ case line == "":
715+ line = word
716+ case len(line)+1+len(word) <= width:
717+ line += " " + word
718+ default:
719+ lines = append(lines, line)
720+ line = word
721+ }
722+ }
723+ if line != "" {
724+ lines = append(lines, line)
725+ }
726+ return strings.Join(lines, "\n")
727+ }
+1002−0internal/tui/sidebar_test.goaddedView File
@@ -0,0 +1,1002 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+
12+ "harness/internal/agent"
13+ "harness/internal/approval"
14+ "harness/internal/llm"
15+ "harness/internal/metrics"
16+ "harness/internal/subagent"
17+ "harness/internal/tools"
18+ )
19+
20+ // fakeAgents stands in for the supervisor: the TUI only ever asks it for a
21+ // snapshot, takes finished reports off it, and tells it to pause, resume or
22+ // kill by id.
23+ type fakeAgents struct {
24+ rows []subagent.Info
25+ reports []subagent.Info
26+ acted []string
27+ // onAct fires inside Pause/Resume/Kill, standing in for the real
28+ // supervisor's Changed callback — which reaches the program through an
29+ // unbuffered channel the update loop is the only reader of.
30+ onAct func()
31+ // unclaimable are reports the supervisor has already handed to the model
32+ // another way, which the front end must not send again.
33+ unclaimable map[string]bool
34+ }
35+
36+ func (f *fakeAgents) Snapshot() []subagent.Info { return f.rows }
37+ func (f *fakeAgents) TakeReports() []subagent.Info { r := f.reports; f.reports = nil; return r }
38+ func (f *fakeAgents) Live() int {
39+ var n int
40+ for _, r := range f.rows {
41+ if r.Status.Live() {
42+ n++
43+ }
44+ }
45+ return n
46+ }
47+ func (f *fakeAgents) act(what string) error {
48+ f.acted = append(f.acted, what)
49+ if f.onAct != nil {
50+ f.onAct()
51+ }
52+ return nil
53+ }
54+
55+ func (f *fakeAgents) Pause(id string) error { return f.act("pause " + id) }
56+ func (f *fakeAgents) Resume(id string) error { return f.act("resume " + id) }
57+ func (f *fakeAgents) Kill(id string) error { return f.act("kill " + id) }
58+ func (f *fakeAgents) Subscribe(func(subagent.Event), func()) {}
59+
60+ // ClaimReport refuses a report the model has already been given another way.
61+ func (f *fakeAgents) ClaimReport(id string) bool { return !f.unclaimable[id] }
62+
63+ func info(id, preset string, status subagent.Status) subagent.Info {
64+ return subagent.Info{
65+ ID: id, Preset: preset, Model: "claude-haiku-4-5", Task: "do the thing",
66+ Status: status, Turns: 3, Started: time.Now().Add(-20 * time.Second),
67+ Usage: llm.Usage{InputTokens: 1000, OutputTokens: 200},
68+ }
69+ }
70+
71+ // newAgentModel is a model with a sidebar to drive.
72+ func newAgentModel(t *testing.T, rows ...subagent.Info) (*model, *fakeAgents) {
73+ t.Helper()
74+ f := &fakeAgents{rows: rows}
75+ m := newModel(context.Background(), Options{
76+ Client: llm.New(llm.WithAPIKey("x")),
77+ Root: "/tmp/repo",
78+ Policy: approval.New(nil),
79+ Agents: f,
80+ })
81+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
82+ m.Update(evAgents{})
83+ return m, f
84+ }
85+
86+ func TestCtrlBOpensAndClosesTheSidebar(t *testing.T) {
87+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
88+
89+ if strings.Contains(m.View(), "explore") {
90+ t.Fatal("the sidebar is showing before it was opened")
91+ }
92+ m.Update(key("ctrl+b"))
93+ if !strings.Contains(m.View(), "explore") {
94+ t.Fatalf("ctrl+b did not open the sidebar:\n%s", m.View())
95+ }
96+ m.Update(key("ctrl+b"))
97+ if strings.Contains(m.View(), "explore") {
98+ t.Error("ctrl+b did not close the sidebar again")
99+ }
100+ }
101+
102+ // The row is what tells you whether an agent is working or stuck, so it has to
103+ // carry the id, the preset, the model and the numbers.
104+ func TestARowNamesTheAgentAndWhatItCosts(t *testing.T) {
105+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
106+ m.Update(key("ctrl+b"))
107+
108+ view := m.View()
109+ for _, want := range []string{"a1", "explore", "Haiku", "3 turns"} {
110+ if !strings.Contains(view, want) {
111+ t.Errorf("the sidebar never says %q:\n%s", want, view)
112+ }
113+ }
114+ }
115+
116+ func TestEveryStatusIsLegibleInTheSidebar(t *testing.T) {
117+ rows := []subagent.Info{
118+ info("a1", "explore", subagent.Queued),
119+ info("a2", "plan", subagent.Running),
120+ info("a3", "general", subagent.Waiting),
121+ info("a4", "explore", subagent.Paused),
122+ info("a5", "plan", subagent.Done),
123+ info("a6", "general", subagent.Failed),
124+ info("a7", "explore", subagent.Killed),
125+ }
126+ m, _ := newAgentModel(t, rows...)
127+ m.Update(key("ctrl+b"))
128+
129+ view := m.View()
130+ for _, r := range rows {
131+ if !strings.Contains(view, r.ID) {
132+ t.Errorf("%s (%s) is missing from the sidebar:\n%s", r.ID, r.Status, view)
133+ }
134+ }
135+ for _, word := range []string{"queued", "waiting", "paused", "done", "failed", "stopped"} {
136+ if !strings.Contains(view, word) {
137+ t.Errorf("no row reads as %q:\n%s", word, view)
138+ }
139+ }
140+ }
141+
142+ // Selecting an agent shows its own transcript in the main pane: the one place
143+ // the turns it took are visible at all.
144+ func TestOpeningAnAgentShowsItsOwnThread(t *testing.T) {
145+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running), info("a2", "plan", subagent.Running))
146+ m.tr.addUser("what my own thread says")
147+ m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore",
148+ Agent: agent.Event{Kind: agent.EventText, Text: "what the first agent said"}}})
149+ m.Update(evSub{ev: subagent.Event{ID: "a2", Preset: "plan",
150+ Agent: agent.Event{Kind: agent.EventText, Text: "what the second agent said"}}})
151+
152+ m.Update(key("ctrl+b"))
153+ m.Update(key("enter"))
154+ view := m.View()
155+ if !strings.Contains(view, "what the first agent said") {
156+ t.Fatalf("a1's thread is not on screen:\n%s", view)
157+ }
158+ if strings.Contains(view, "what my own thread says") {
159+ t.Error("the main thread is still on screen underneath the agent's")
160+ }
161+
162+ m.Update(key("down"))
163+ m.Update(key("enter"))
164+ view = m.View()
165+ if !strings.Contains(view, "what the second agent said") {
166+ t.Fatalf("moving to a2 did not show its thread:\n%s", view)
167+ }
168+ if strings.Contains(view, "what the first agent said") {
169+ t.Error("a1's thread is still showing while a2 is selected")
170+ }
171+ }
172+
173+ func TestEscLeavesTheThreadThenClosesTheSidebar(t *testing.T) {
174+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
175+ m.tr.addUser("my own thread")
176+ m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore",
177+ Agent: agent.Event{Kind: agent.EventText, Text: "the agent's thread"}}})
178+ m.Update(key("ctrl+b"))
179+ m.Update(key("enter"))
180+
181+ m.Update(key("esc"))
182+ if m.viewing != "" {
183+ t.Errorf("still viewing %q after esc", m.viewing)
184+ }
185+ if !strings.Contains(m.View(), "explore") {
186+ t.Error("esc closed the whole sidebar rather than leaving the thread")
187+ }
188+ if !strings.Contains(m.View(), "my own thread") {
189+ t.Error("esc did not put your own thread back")
190+ }
191+
192+ m.Update(key("esc"))
193+ if strings.Contains(m.View(), "explore") {
194+ t.Error("a second esc did not close the sidebar")
195+ }
196+ }
197+
198+ func TestPauseResumeAndKillActOnTheSelectedAgent(t *testing.T) {
199+ m, f := newAgentModel(t,
200+ info("a1", "explore", subagent.Running),
201+ info("a2", "plan", subagent.Paused),
202+ )
203+ m.Update(key("ctrl+b"))
204+
205+ press(t, m, key("p")) // a1 is running, so p pauses it
206+ press(t, m, key("down"))
207+ press(t, m, key("p")) // a2 is paused, so p resumes it
208+ press(t, m, key("k"))
209+
210+ if strings.Join(f.acted, ", ") != "pause a1, resume a2, kill a2" {
211+ t.Errorf("the sidebar asked for %v", f.acted)
212+ }
213+ }
214+
215+ // Pausing or killing must happen off the update loop. The supervisor tells
216+ // the front end that something changed by sending it a message, and the
217+ // channel that carries it is read by the update loop itself — so a supervisor
218+ // call made from inside Update deadlocks the whole program, keyboard included.
219+ func TestControllingAnAgentHappensOffTheUpdateLoop(t *testing.T) {
220+ m, f := newAgentModel(t, info("a1", "explore", subagent.Running))
221+ m.Update(key("ctrl+b"))
222+
223+ var inUpdate, calledInUpdate bool
224+ f.onAct = func() {
225+ if inUpdate {
226+ calledInUpdate = true
227+ }
228+ }
229+
230+ inUpdate = true
231+ _, cmd := m.Update(key("p"))
232+ inUpdate = false
233+
234+ if calledInUpdate {
235+ t.Fatal("the supervisor was called from inside Update, which deadlocks the program")
236+ }
237+ if cmd == nil {
238+ t.Fatal("pressing p produced no command, so nothing will ever pause the agent")
239+ }
240+ drain(t, m, cmd)
241+ if len(f.acted) != 1 || f.acted[0] != "pause a1" {
242+ t.Errorf("after the command ran, the supervisor saw %v", f.acted)
243+ }
244+ }
245+
246+ // press feeds a key and then runs whatever command came back, the way the
247+ // program's scheduler would.
248+ func press(t *testing.T, m *model, msg tea.Msg) {
249+ t.Helper()
250+ _, cmd := m.Update(msg)
251+ drain(t, m, cmd)
252+ }
253+
254+ // While the sidebar has the keys, what you type must not land in the prompt —
255+ // otherwise "k" both kills an agent and types a letter.
256+ func TestKeysGoToTheSidebarNotThePromptWhileItIsOpen(t *testing.T) {
257+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
258+ m.Update(key("ctrl+b"))
259+ typeIn(m, "pk")
260+
261+ if got := m.input.Value(); got != "" {
262+ t.Errorf("the prompt swallowed %q while the sidebar was open", got)
263+ }
264+ m.Update(key("esc"))
265+ typeIn(m, "hello")
266+ if got := m.input.Value(); got != "hello" {
267+ t.Errorf("after esc the prompt has %q, want %q", got, "hello")
268+ }
269+ }
270+
271+ // A subagent's events belong to its own transcript, not to yours.
272+ func TestSubagentOutputNeverLandsInTheMainTranscript(t *testing.T) {
273+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
274+ m.Update(evSub{ev: subagent.Event{ID: "a1", Preset: "explore",
275+ Agent: agent.Event{Kind: agent.EventToolStart, Tool: "grep", Input: []byte(`{"pattern":"x"}`)}}})
276+
277+ if strings.Contains(m.tr.render(), "grep") {
278+ t.Errorf("the agent's tool call landed in the main transcript:\n%s", m.tr.render())
279+ }
280+ }
281+
282+ // A finished agent is worth a line in your own transcript even when its report
283+ // is not being delivered yet: an agent that finishes invisibly is one you go
284+ // looking for.
285+ func TestAFinishedAgentIsNoticedInTheMainTranscript(t *testing.T) {
286+ m, f := newAgentModel(t, info("a1", "explore", subagent.Running))
287+ done := info("a1", "explore", subagent.Done)
288+ done.Report = "found it in loop.go"
289+ f.rows = []subagent.Info{done}
290+ f.reports = []subagent.Info{done}
291+ typeIn(m, "half a sentence") // holds the report back
292+
293+ m.Update(evAgents{})
294+ if !strings.Contains(m.tr.render(), "a1") {
295+ t.Errorf("nothing in the transcript says a1 finished:\n%s", m.tr.render())
296+ }
297+ }
298+
299+ // A turn must not start under your fingers: it blurs the prompt and eats what
300+ // you were typing.
301+ func TestAReportIsHeldWhileYouAreTyping(t *testing.T) {
302+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
303+ f.reports = []subagent.Info{info("a1", "explore", subagent.Done)}
304+ typeIn(m, "half a sentence")
305+
306+ m.Update(evAgents{})
307+ if len(m.pendingReports) != 1 {
308+ t.Fatalf("the report was not held: %d pending", len(m.pendingReports))
309+ }
310+ if m.phase != phaseIdle {
311+ t.Errorf("a turn started while the prompt had text in it")
312+ }
313+ if got := m.input.Value(); got != "half a sentence" {
314+ t.Errorf("the prompt now reads %q", got)
315+ }
316+ }
317+
318+ func TestReportsAreHeldWhileAnApprovalIsOnScreen(t *testing.T) {
319+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
320+ f.reports = []subagent.Info{info("a1", "explore", subagent.Done)}
321+ pend(m, approval.Request{Tool: "bash", Summary: "run something"})
322+
323+ m.Update(evAgents{})
324+ if len(m.pendingReports) != 1 {
325+ t.Fatalf("the report was not held while an approval was up")
326+ }
327+ }
328+
329+ // Idle and empty-handed, the report goes into the conversation by itself and
330+ // the agent is set going on it — no keystroke required.
331+ func TestAReportStartsATurnWhenYouAreIdle(t *testing.T) {
332+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
333+ m.opts.Agent = newQuietAgent(t)
334+
335+ done := info("a1", "explore", subagent.Done)
336+ done.Report = "found it in loop.go"
337+ f.reports = []subagent.Info{done}
338+
339+ m.Update(evAgents{})
340+ if len(m.pendingReports) != 0 {
341+ t.Fatalf("the report is still pending with nothing in the way")
342+ }
343+ if m.phase != phaseRunning {
344+ t.Errorf("phase = %v, want a turn to have started", m.phase)
345+ }
346+
347+ var sent string
348+ for _, msg := range m.opts.Agent.State().History() {
349+ for _, b := range msg.Content {
350+ sent += b.Text
351+ }
352+ }
353+ if !strings.Contains(sent, "found it in loop.go") {
354+ t.Errorf("the conversation never got the report:\n%s", sent)
355+ }
356+ }
357+
358+ // Whatever was typed before the report landed is still yours to send, and the
359+ // report goes up ahead of it rather than being glued onto it.
360+ func TestAHeldReportGoesUpWhenYouSendYourOwnMessage(t *testing.T) {
361+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
362+ m.opts.Agent = newQuietAgent(t)
363+ done := info("a1", "explore", subagent.Done)
364+ done.Report = "found it in loop.go"
365+ f.reports = []subagent.Info{done}
366+
367+ typeIn(m, "and now fix it")
368+ m.Update(evAgents{})
369+ m.Update(key("enter"))
370+
371+ history := m.opts.Agent.State().History()
372+ if len(history) != 1 {
373+ t.Fatalf("history has %d messages, want the report as its own message", len(history))
374+ }
375+ if !strings.Contains(history[0].Content[0].Text, "found it in loop.go") {
376+ t.Errorf("the report did not go up ahead of the message:\n%+v", history[0])
377+ }
378+ if len(m.pendingReports) != 0 {
379+ t.Error("the report is still pending after a turn started")
380+ }
381+ }
382+
383+ // newQuietAgent is a real agent whose turns are never actually run: the tests
384+ // above stop at the command, so nothing reaches the network.
385+ func newQuietAgent(t *testing.T) *agent.Agent {
386+ t.Helper()
387+ log := metrics.NewWriter(nil, "test")
388+ state := agent.NewState()
389+ reg := tools.NewRegistry(log)
390+ return agent.New(llm.New(llm.WithAPIKey("x")), reg, state, log, agent.Options{})
391+ }
392+
393+ // A sub-cent agent showing "$0.00" reads as free. Below a cent the row says
394+ // nothing, and the line in the transcript gives the exact figure.
395+ func TestSubCentCostsAreNotShownAsZero(t *testing.T) {
396+ cheap := info("a1", "explore", subagent.Done)
397+ cheap.Usage = llm.Usage{InputTokens: 1000, OutputTokens: 40}
398+ dear := info("a2", "plan", subagent.Done)
399+ dear.Model = "claude-opus-5"
400+ dear.Usage = llm.Usage{InputTokens: 100000, OutputTokens: 4000}
401+
402+ m, _ := newAgentModel(t, cheap, dear)
403+ m.Update(key("ctrl+b"))
404+ view := m.View()
405+
406+ if strings.Contains(view, "$0.00") {
407+ t.Errorf("a sub-cent agent is shown as costing nothing:\n%s", view)
408+ }
409+ if !strings.Contains(view, "$0.6") {
410+ t.Errorf("the expensive agent's cost is missing:\n%s", view)
411+ }
412+ }
413+
414+ // Thirty columns is not enough for "claude-haiku-4-5" and a duration and a
415+ // turn count, so the row carries the family and the thread's header carries
416+ // the rest.
417+ func TestShortModelKeepsTheFamily(t *testing.T) {
418+ cases := map[string]string{
419+ "claude-haiku-4-5": "Haiku",
420+ "claude-opus-5": "Opus",
421+ "claude-haiku-4-5-20251001": "Haiku",
422+ "some-gateway/mistral-large": "some-gateway/mistral-large",
423+ "": "",
424+ }
425+ for id, want := range cases {
426+ if got := shortModel(id); got != want {
427+ t.Errorf("shortModel(%q) = %q, want %q", id, got, want)
428+ }
429+ }
430+ }
431+
432+ // A minute in, "124s" stops being a number anyone reads as a duration.
433+ func TestCompactDurationTurnsMinutesIntoMinutes(t *testing.T) {
434+ cases := []struct {
435+ d time.Duration
436+ want string
437+ }{
438+ {3 * time.Second, "3s"},
439+ {59 * time.Second, "59s"},
440+ {time.Minute + 4*time.Second, "1m04s"},
441+ {124 * time.Second, "2m04s"},
442+ {2 * time.Hour, "120m00s"},
443+ }
444+ for _, c := range cases {
445+ if got := compactDuration(c.d); got != c.want {
446+ t.Errorf("compactDuration(%v) = %q, want %q", c.d, got, c.want)
447+ }
448+ }
449+ }
450+
451+ // The pane is the full height of the screen: two short rows leave the status
452+ // line running under it with nothing beside it.
453+ func TestTheSidebarIsAsTallAsTheScreen(t *testing.T) {
454+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
455+ m.Update(tea.WindowSizeMsg{Width: 78, Height: 16})
456+ m.Update(key("ctrl+b"))
457+
458+ for i, line := range strings.Split(m.View(), "\n") {
459+ if !strings.Contains(line, "│") {
460+ t.Errorf("line %d has no sidebar beside it: %q", i, line)
461+ }
462+ }
463+ }
464+
465+ // Nothing the model draws may be wider than the terminal it is drawn in: a
466+ // composite that overflows wraps, and a wrapped full-screen layout tears.
467+ func TestTheWholeScreenFitsTheTerminalWithTheSidebarOpen(t *testing.T) {
468+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
469+ for _, size := range []tea.WindowSizeMsg{{Width: 78, Height: 14}, {Width: 100, Height: 30}, {Width: 62, Height: 20}} {
470+ m.Update(size)
471+ m.Update(evAgents{})
472+ for _, open := range []bool{true, false} {
473+ if m.bar.open != open {
474+ m.Update(key("ctrl+b"))
475+ }
476+ for i, line := range strings.Split(m.View(), "\n") {
477+ if w := lipgloss.Width(line); w > size.Width {
478+ t.Errorf("at %dx%d (sidebar open=%v) line %d is %d columns wide",
479+ size.Width, size.Height, open, i, w)
480+ }
481+ }
482+ }
483+ }
484+ }
485+
486+ // The sidebar owns the keyboard while it is open, with two exceptions: the
487+ // keys that stop things. Swallowing ctrl+c would mean opening the pane to
488+ // watch an agent left you unable to interrupt your own turn.
489+ func TestInterruptAndExitStillWorkWithTheSidebarOpen(t *testing.T) {
490+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
491+ m.phase = phaseRunning
492+ var interrupted bool
493+ m.cancel = func() { interrupted = true }
494+ m.Update(key("ctrl+b"))
495+
496+ m.Update(key("ctrl+c"))
497+ if !interrupted {
498+ t.Error("ctrl+c did not interrupt the turn while the sidebar was open")
499+ }
500+
501+ m.phase = phaseIdle
502+ m.Update(key("ctrl+d"))
503+ if !m.quitting {
504+ t.Error("ctrl+d did not exit while the sidebar was open")
505+ }
506+ }
507+
508+ // click presses the left button where the model says something is drawn.
509+ func click(x, y int) tea.MouseMsg {
510+ return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}
511+ }
512+
513+ func TestClickingTheAgentsPillTogglesTheSidebar(t *testing.T) {
514+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
515+ m.View() // the pill's position is recorded as it is drawn
516+
517+ if m.pill.x1 == 0 {
518+ t.Fatal("the agents pill was never drawn, so it cannot be clicked")
519+ }
520+ m.Update(click(m.pill.x0, m.pill.y0))
521+ if !m.bar.open {
522+ t.Error("clicking the pill did not open the sidebar")
523+ }
524+ m.View()
525+ m.Update(click(m.pill.x0, m.pill.y0))
526+ if m.bar.open {
527+ t.Error("clicking the pill again did not close it")
528+ }
529+ }
530+
531+ // One click selects a row; clicking the row already under the cursor opens it,
532+ // which is what anyone who double-clicks gets.
533+ func TestClickingARowSelectsItAndClickingAgainOpensIt(t *testing.T) {
534+ m, _ := newAgentModel(t,
535+ info("a1", "explore", subagent.Running),
536+ info("a2", "plan", subagent.Running),
537+ )
538+ m.Update(key("ctrl+b"))
539+ m.View()
540+
541+ if len(m.bar.hits) != 2 {
542+ t.Fatalf("the sidebar recorded %d clickable rows, want 2", len(m.bar.hits))
543+ }
544+ second := m.bar.hits[1]
545+ x := m.width - sidebarWidth + 2
546+
547+ m.Update(click(x, second.y0))
548+ if cur, _ := m.bar.selected(); cur.ID != "a2" {
549+ t.Fatalf("clicking a2's row selected %q", cur.ID)
550+ }
551+ if m.viewing != "" {
552+ t.Error("one click opened the thread; it should only select")
553+ }
554+
555+ m.View()
556+ m.Update(click(x, second.y0))
557+ if m.viewing != "a2" {
558+ t.Errorf("clicking the selected row did not open it; viewing = %q", m.viewing)
559+ }
560+ }
561+
562+ // A click in the transcript is not a click in the sidebar, however far right
563+ // it lands while the pane is closed.
564+ func TestClicksOutsideTheSidebarDoNothing(t *testing.T) {
565+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
566+ m.View()
567+ m.Update(click(m.width-2, 3))
568+ if m.bar.open || m.viewing != "" {
569+ t.Error("a click in the transcript opened the sidebar")
570+ }
571+ }
572+
573+ func TestMouseCommandTogglesReporting(t *testing.T) {
574+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
575+ m.mouseOn = true
576+
577+ m.submit("/mouse")
578+ if m.mouseOn {
579+ t.Error("/mouse did not turn reporting off")
580+ }
581+ if !strings.Contains(m.tr.render(), "mouse off") {
582+ t.Errorf("nothing said what happened:\n%s", m.tr.render())
583+ }
584+ m.submit("/mouse")
585+ if !m.mouseOn {
586+ t.Error("/mouse did not turn reporting back on")
587+ }
588+ }
589+
590+ // The rows' click targets have to agree with where the rows were actually
591+ // drawn. Recorded coordinates that are internally consistent but a line off
592+ // the screen would make every click select the row above.
593+ func TestRowClickTargetsMatchWhereTheRowsAreDrawn(t *testing.T) {
594+ m, _ := newAgentModel(t,
595+ info("a1", "explore", subagent.Running),
596+ info("a2", "plan", subagent.Running),
597+ info("a3", "general", subagent.Done),
598+ )
599+ m.Update(key("ctrl+b"))
600+ lines := strings.Split(m.View(), "\n")
601+
602+ if len(m.bar.hits) != 3 {
603+ t.Fatalf("recorded %d click targets, want 3", len(m.bar.hits))
604+ }
605+ for _, h := range m.bar.hits {
606+ if h.y0 >= len(lines) {
607+ t.Fatalf("row %s is recorded at line %d of a %d-line screen", h.id, h.y0, len(lines))
608+ }
609+ if !strings.Contains(lines[h.y0], h.id) {
610+ t.Errorf("row %s is recorded at line %d, which reads %q", h.id, h.y0, lines[h.y0])
611+ }
612+ }
613+ }
614+
615+ // The same for the pill: it is on the line it says it is.
616+ func TestThePillIsRecordedOnTheLineItIsDrawnOn(t *testing.T) {
617+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
618+ lines := strings.Split(m.View(), "\n")
619+ if m.pill.y0 >= len(lines) || !strings.Contains(lines[m.pill.y0], "agents") {
620+ t.Errorf("the pill is recorded at line %d, which reads %q", m.pill.y0, lines[m.pill.y0])
621+ }
622+ }
623+
624+ // A subagent's approval can arrive when nothing of yours is running, and
625+ // answering it must leave the session where it found it. Dropping into
626+ // "running" with no turn in flight strands the UI: enter is refused, esc only
627+ // says "interrupting", and no further report is ever delivered.
628+ func TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle(t *testing.T) {
629+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
630+
631+ reply := pend(m, approval.Request{Tool: "bash", Summary: "run something", Agent: "a1"})
632+ if m.phase != phaseApproving {
633+ t.Fatalf("phase = %v while an approval is up", m.phase)
634+ }
635+ m.Update(key("y"))
636+ if got := <-reply; got != approval.Once {
637+ t.Fatalf("decision = %v", got)
638+ }
639+ if m.phase != phaseIdle {
640+ t.Errorf("phase = %v after answering; nothing of yours was running", m.phase)
641+ }
642+
643+ // The prompt is usable again, which is the thing being stranded costs you.
644+ typeIn(m, "next")
645+ if m.input.Value() != "next" {
646+ t.Errorf("the prompt has %q", m.input.Value())
647+ }
648+ }
649+
650+ // The same approval answered during a turn puts the turn back.
651+ func TestAnsweringAnApprovalDuringATurnStaysRunning(t *testing.T) {
652+ m, _ := newAgentModel(t)
653+ m.phase = phaseRunning
654+
655+ reply := pend(m, approval.Request{Tool: "bash", Summary: "run something"})
656+ m.Update(key("y"))
657+ <-reply
658+ if m.phase != phaseRunning {
659+ t.Errorf("phase = %v, want the turn to carry on", m.phase)
660+ }
661+ }
662+
663+ // A turn ending cancels the approvals that belonged to it — but a subagent's
664+ // approval belongs to the subagent, which is still running. Denying it because
665+ // the parent finished takes the decision away from the user and tells the
666+ // child it was refused.
667+ func TestATurnEndingLeavesSubagentApprovalsAlone(t *testing.T) {
668+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
669+ m.phase = phaseRunning
670+
671+ mine := pend(m, approval.Request{Tool: "bash", Summary: "the parent's"})
672+ child := pend(m, approval.Request{Tool: "bash", Summary: "the child's", Agent: "a1"})
673+
674+ m.Update(turnDone{})
675+
676+ select {
677+ case got := <-mine:
678+ if got != approval.Deny {
679+ t.Errorf("the parent's own request ended as %v, want denied", got)
680+ }
681+ default:
682+ t.Error("the parent's request was left hanging after its turn ended")
683+ }
684+ select {
685+ case got := <-child:
686+ t.Fatalf("the child's request was answered %v by a turn it had nothing to do with", got)
687+ default:
688+ }
689+ if len(m.pending) != 1 || m.pending[0].req.Agent != "a1" {
690+ t.Fatalf("pending = %+v, want the child's request still waiting", m.pending)
691+ }
692+ if m.phase != phaseApproving {
693+ t.Errorf("phase = %v, want the box still up for the child", m.phase)
694+ }
695+ }
696+
697+ // A report the agent already collected with agent_wait is worth a line in the
698+ // transcript, and nothing more: sending it again would repeat it in the
699+ // conversation and start a turn to acknowledge something it just read.
700+ func TestAReportTheAgentAlreadyHasIsNotSentAgain(t *testing.T) {
701+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
702+ m.opts.Agent = newQuietAgent(t)
703+
704+ done := info("a1", "explore", subagent.Done)
705+ done.Report = "found it in loop.go"
706+ f.unclaimable = map[string]bool{"a1": true}
707+ f.reports = []subagent.Info{done}
708+
709+ m.Update(evAgents{})
710+
711+ if len(m.pendingReports) != 0 {
712+ t.Errorf("%d reports are queued for a conversation that already has them", len(m.pendingReports))
713+ }
714+ if m.phase != phaseIdle {
715+ t.Error("a turn was started to hand over a report the agent already read")
716+ }
717+ if len(m.opts.Agent.State().History()) != 0 {
718+ t.Error("the report went into the conversation twice")
719+ }
720+ if !strings.Contains(m.tr.render(), "a1") {
721+ t.Errorf("the transcript never mentions that a1 finished:\n%s", m.tr.render())
722+ }
723+ }
724+
725+ // With several agents running, "bash wants to run …" is not a question anyone
726+ // can answer: the box has to say who is asking.
727+ func TestTheApprovalBoxNamesTheAgentAsking(t *testing.T) {
728+ m, _ := newAgentModel(t, info("a3", "explore", subagent.Running))
729+ pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "rm -rf build", Agent: "a3"})
730+
731+ view := m.View()
732+ if !strings.Contains(view, "a3") {
733+ t.Errorf("the approval box does not say which agent is asking:\n%s", view)
734+ }
735+ if !strings.Contains(view, "explore") {
736+ t.Errorf("the approval box does not say what kind of agent it is:\n%s", view)
737+ }
738+ }
739+
740+ // Your own agent's requests are not labelled: there is nobody else it could be.
741+ func TestYourOwnApprovalIsNotLabelledWithAnAgent(t *testing.T) {
742+ m, _ := newAgentModel(t)
743+ m.phase = phaseRunning
744+ pend(m, approval.Request{Tool: "bash", Summary: "run a shell command"})
745+
746+ if got := m.View(); strings.Contains(got, " · bash") {
747+ t.Errorf("the box labels the session's own request as if it came from an agent:\n%s", got)
748+ }
749+ }
750+
751+ // On a terminal too narrow for both panes the status line is not drawn, so the
752+ // pill is nowhere — and a click at its remembered position must not toggle the
753+ // pane shut from under a hint line that happens to be there instead.
754+ func TestThePillIsForgottenWhenTheStatusLineIsNotDrawn(t *testing.T) {
755+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
756+ m.View()
757+ was := m.pill
758+
759+ m.Update(tea.WindowSizeMsg{Width: 50, Height: 20})
760+ m.Update(key("ctrl+b"))
761+ if !m.fullScreenSidebar() {
762+ t.Fatal("50 columns should not be showing both panes")
763+ }
764+ m.View()
765+
766+ m.Update(click(was.x0, was.y0))
767+ if !m.bar.open {
768+ t.Error("a click where the pill used to be closed the pane")
769+ }
770+ }
771+
772+ // A report held back because you were typing goes up as soon as the prompt is
773+ // empty again, rather than waiting for something else to happen.
774+ func TestAHeldReportGoesUpWhenThePromptIsClearedAgain(t *testing.T) {
775+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
776+ m.opts.Agent = newQuietAgent(t)
777+ done := info("a1", "explore", subagent.Done)
778+ done.Report = "found it in loop.go"
779+ f.reports = []subagent.Info{done}
780+
781+ typeIn(m, "ab")
782+ m.Update(evAgents{})
783+ if len(m.pendingReports) != 1 {
784+ t.Fatal("the report was not held while the prompt had text in it")
785+ }
786+
787+ m.Update(key("backspace"))
788+ m.Update(key("backspace"))
789+ if len(m.pendingReports) != 0 {
790+ t.Errorf("clearing the prompt did not release the report")
791+ }
792+ if m.phase != phaseRunning {
793+ t.Errorf("phase = %v, want the turn to have started", m.phase)
794+ }
795+ }
796+
797+ // The effort pill is a claim about what the next turn will ask for. On a model
798+ // that does not take the level, the request goes out without it — so the pill
799+ // has to say so rather than reporting a setting that is not in force.
800+ func TestTheEffortPillSaysWhenTheModelIgnoresIt(t *testing.T) {
801+ m, _ := newAgentModel(t)
802+ m.opts.Effort = "high"
803+
804+ m.opts.Client.SetModel("claude-opus-5")
805+ if got := m.View(); !strings.Contains(got, "● high") || strings.Contains(got, "not on") {
806+ t.Errorf("on a model that takes effort the pill should read plainly:\n%s", got)
807+ }
808+
809+ m.opts.Client.SetModel("claude-haiku-4-5")
810+ view := m.View()
811+ if !strings.Contains(view, "high") {
812+ t.Fatalf("the pill vanished entirely:\n%s", view)
813+ }
814+ if !strings.Contains(view, "not on Haiku") {
815+ t.Errorf("the pill still claims a level the model rejects:\n%s", view)
816+ }
817+ }
818+
819+ // The front end holds a report from the moment it is drained until the moment
820+ // there is a turn to put it in, and agent_wait can hand the same report to the
821+ // model in between. So delivery is decided when the message is written, by
822+ // asking the supervisor — not by trusting a copy taken minutes earlier.
823+ func TestAReportTheModelWasGivenMeanwhileIsNotInjected(t *testing.T) {
824+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
825+ m.opts.Agent = newQuietAgent(t)
826+
827+ done := info("a1", "explore", subagent.Done)
828+ done.Report = "found it in loop.go"
829+ f.reports = []subagent.Info{done}
830+
831+ // It is held back while the prompt has text in it — which is where the
832+ // parent's own agent_wait call collects it.
833+ typeIn(m, "hold on")
834+ m.Update(evAgents{})
835+ if len(m.pendingReports) != 1 {
836+ t.Fatalf("the report was not held: %d pending", len(m.pendingReports))
837+ }
838+ f.unclaimable = map[string]bool{"a1": true}
839+
840+ m.Update(key("backspace"))
841+ typeIn(m, "")
842+ for i := 0; i < 7; i++ {
843+ m.Update(key("backspace"))
844+ }
845+
846+ if len(m.pendingReports) != 0 {
847+ t.Errorf("%d reports are still queued", len(m.pendingReports))
848+ }
849+ if n := len(m.opts.Agent.State().History()); n != 0 {
850+ t.Errorf("the conversation got %d messages for a report the model already had", n)
851+ }
852+ if m.phase != phaseIdle {
853+ t.Error("a turn was started to hand over a report the model already had")
854+ }
855+ }
856+
857+ // The same path when nobody else has taken it: the report goes up.
858+ func TestAnUnclaimedReportIsStillInjected(t *testing.T) {
859+ m, f := newAgentModel(t, info("a1", "explore", subagent.Done))
860+ m.opts.Agent = newQuietAgent(t)
861+
862+ done := info("a1", "explore", subagent.Done)
863+ done.Report = "found it in loop.go"
864+ f.reports = []subagent.Info{done}
865+
866+ m.Update(evAgents{})
867+ if len(m.pendingReports) != 0 {
868+ t.Errorf("the report was not delivered: %d pending", len(m.pendingReports))
869+ }
870+ history := m.opts.Agent.State().History()
871+ if len(history) != 1 || !strings.Contains(history[0].Content[0].Text, "found it in loop.go") {
872+ t.Errorf("the conversation did not get the report: %+v", history)
873+ }
874+ }
875+
876+ // The thread header is where an agent's settings are legible — the row has no
877+ // space for them — so it names the effort the agent is actually running at.
878+ func TestTheThreadHeaderNamesTheEffort(t *testing.T) {
879+ low := info("a1", "explore", subagent.Running)
880+ low.Effort = "low"
881+ plain := info("a2", "plan", subagent.Running)
882+
883+ m, _ := newAgentModel(t, low, plain)
884+ m.Update(key("ctrl+b"))
885+ m.Update(key("enter"))
886+ if got := m.agentHeader(80); !strings.Contains(got, "low") {
887+ t.Errorf("a1's header does not say what effort it runs at: %q", got)
888+ }
889+
890+ m.Update(key("down"))
891+ if got := m.agentHeader(80); strings.Contains(got, "effort") {
892+ t.Errorf("a2's header invents an effort level: %q", got)
893+ }
894+ }
895+
896+ // The pane owns the keyboard while it has focus, which is what makes k a kill
897+ // rather than a letter — but the prompt has to be reachable without closing
898+ // it. Clicking the prompt is how you get back to typing.
899+ func TestClickingThePromptTakesFocusFromTheSidebar(t *testing.T) {
900+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
901+ m.Update(key("ctrl+b"))
902+
903+ typeIn(m, "no")
904+ if m.input.Value() != "" {
905+ t.Fatalf("the prompt took %q while the pane had focus", m.input.Value())
906+ }
907+
908+ // A click anywhere in the main column — the prompt box included — is a
909+ // click on the conversation, and that is where typing should go.
910+ m.View()
911+ m.Update(click(4, m.height-4))
912+
913+ if !m.bar.open {
914+ t.Error("clicking the prompt closed the sidebar; it should only move focus")
915+ }
916+ typeIn(m, "hello")
917+ if got := m.input.Value(); got != "hello" {
918+ t.Errorf("the prompt has %q after being clicked", got)
919+ }
920+ }
921+
922+ // And clicking back into the pane gives it the keys again.
923+ func TestClickingTheSidebarTakesFocusBack(t *testing.T) {
924+ m, f := newAgentModel(t, info("a1", "explore", subagent.Running))
925+ m.Update(key("ctrl+b"))
926+ m.View()
927+ m.Update(click(4, m.height-4)) // to the prompt
928+
929+ m.View()
930+ x := m.width - sidebarWidth + 2
931+ m.Update(click(x, m.bar.hits[0].y0))
932+
933+ press(t, m, key("p"))
934+ if len(f.acted) != 1 || f.acted[0] != "pause a1" {
935+ t.Errorf("p did not reach the pane after clicking it: %v", f.acted)
936+ }
937+ if m.input.Value() != "" {
938+ t.Errorf("the prompt took the keystroke too: %q", m.input.Value())
939+ }
940+ }
941+
942+ // ctrl+b on a pane that is open but not focused takes you to it rather than
943+ // closing it: the pane is right there, and closing what you are looking at is
944+ // never what that key was for.
945+ func TestCtrlBFocusesAnOpenPaneBeforeClosingIt(t *testing.T) {
946+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
947+
948+ m.Update(key("ctrl+b")) // open, focused
949+ m.View()
950+ m.Update(click(4, m.height-4)) // focus the prompt
951+ if !m.bar.open || m.bar.focused {
952+ t.Fatalf("after clicking the prompt: open=%v focused=%v", m.bar.open, m.bar.focused)
953+ }
954+
955+ m.Update(key("ctrl+b"))
956+ if !m.bar.open || !m.bar.focused {
957+ t.Errorf("ctrl+b on an unfocused pane gave open=%v focused=%v", m.bar.open, m.bar.focused)
958+ }
959+ m.Update(key("ctrl+b"))
960+ if m.bar.open {
961+ t.Error("ctrl+b on a focused pane did not close it")
962+ }
963+ }
964+
965+ // Which pane has the keys has to be visible, or the next keystroke is a guess.
966+ func TestTheScreenSaysWhichPaneHasTheKeys(t *testing.T) {
967+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
968+ m.Update(key("ctrl+b"))
969+ focused := m.View()
970+ if !strings.Contains(focused, "esc back") {
971+ t.Errorf("a focused pane does not offer its keys:\n%s", focused)
972+ }
973+
974+ m.View()
975+ m.Update(click(4, m.height-4))
976+ unfocused := m.View()
977+ if !strings.Contains(unfocused, "ctrl+b") {
978+ t.Errorf("an unfocused pane does not say how to get back to it:\n%s", unfocused)
979+ }
980+ if strings.Contains(unfocused, "p pause") {
981+ t.Errorf("an unfocused pane still offers keys it will not answer:\n%s", unfocused)
982+ }
983+ }
984+
985+ // A turn ending puts the keyboard back in the prompt — unless the pane has it,
986+ // in which case taking it away mid-keystroke would be the bug this focus
987+ // separation exists to avoid.
988+ func TestATurnEndingDoesNotStealFocusFromThePane(t *testing.T) {
989+ m, _ := newAgentModel(t, info("a1", "explore", subagent.Running))
990+ m.opts.SessionDir = t.TempDir()
991+ m.Update(key("ctrl+b"))
992+ m.phase = phaseRunning
993+
994+ m.Update(turnDone{})
995+ if !m.bar.focused {
996+ t.Fatal("the pane lost focus when a turn ended")
997+ }
998+ typeIn(m, "no")
999+ if got := m.input.Value(); got != "" {
1000+ t.Errorf("the prompt took %q after the turn ended", got)
1001+ }
1002+ }
+5−0internal/tui/styles.goView File
@@ -72,6 +72,11 @@ var (
7272 styBoxFocus lipgloss.Style
7373 styBoxBlur lipgloss.Style
7474
75+ // The agents pane hangs off the right-hand edge, divided from the
76+ // transcript by a rule rather than boxed in: a full border beside a
77+ // transcript reads as a second window.
78+ styBar lipgloss.Style
79+
7580 // The approval box is the one place the UI is allowed to shout: it is the
7681 // only moment a human has to decide something.
7782 styModal lipgloss.Style
+105−12internal/tui/testdata/mockapi/main.goView File
@@ -12,6 +12,11 @@
1212 // tool_result comes back it streams a closing sentence and stops. That covers
1313 // streaming, tool rendering, approve, deny, and interrupt.
1414 //
15+ // With -spawn the script changes: the first turn asks for two subagents
16+ // instead of a shell command, and every request carrying a subagent's system
17+ // prompt is answered as that subagent — a short report, after a pause long
18+ // enough to watch the sidebar fill in.
19+ //
1520 // Flags: -addr to move it, -fail to make the command exit non-zero, -slow to
1621 // stretch the stream out for watching the spinner and ctrl-c.
1722 package main
@@ -24,28 +29,65 @@ import (
2429 "log"
2530 "net/http"
2631 "strings"
32+ "sync"
2733 "time"
2834 )
2935
3036 var (
31- addr = flag.String("addr", "127.0.0.1:8899", "listen address")
32- slow = flag.Duration("slow", 0, "pause between streamed words, e.g. 150ms")
33- cmd = flag.String("cmd", "git status --short && go build ./...", "command the model asks to run")
37+ addr = flag.String("addr", "127.0.0.1:8899", "listen address")
38+ slow = flag.Duration("slow", 0, "pause between streamed words, e.g. 150ms")
39+ cmd = flag.String("cmd", "git status --short && go build ./...", "command the model asks to run")
40+ spawn = flag.Bool("spawn", false, "ask for two subagents on the first turn instead of a shell command")
41+ work = flag.Duration("work", 4*time.Second, "how long a subagent takes to answer, with -spawn")
3442 )
3543
44+ // listedOnce keeps the model-listing note to one line however many turns run.
45+ var listedOnce sync.Once
46+
3647 func main() {
3748 flag.Parse()
3849 http.HandleFunc("/v1/messages", handle)
50+ http.HandleFunc("/v1/models", models)
3951 log.Printf("mock Messages API on http://%s", *addr)
4052 log.Fatal(http.ListenAndServe(*addr, nil))
4153 }
4254
55+ // models answers GET /v1/models, which the model picker opens on and which the
56+ // harness reads at startup to tell the agent what it may put a subagent on.
57+ func models(w http.ResponseWriter, r *http.Request) {
58+ w.Header().Set("Content-Type", "application/json")
59+ json.NewEncoder(w).Encode(map[string]any{
60+ "data": []map[string]any{
61+ {"id": "claude-fable-5-1", "display_name": "Claude Fable 5.1", "created_at": "2026-06-01T00:00:00Z", "max_input_tokens": 1000000},
62+ {"id": "claude-opus-5", "display_name": "Claude Opus 5", "created_at": "2026-04-01T00:00:00Z", "max_input_tokens": 1000000},
63+ {"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5", "created_at": "2026-02-01T00:00:00Z", "max_input_tokens": 1000000},
64+ {"id": "claude-haiku-4-5", "display_name": "Claude Haiku 4.5", "created_at": "2025-10-01T00:00:00Z", "max_input_tokens": 200000},
65+ },
66+ "has_more": false,
67+ })
68+ }
69+
4370 func handle(w http.ResponseWriter, r *http.Request) {
4471 body, _ := io.ReadAll(r.Body)
4572 // A tool_result in the history means the harness ran (or refused) our
4673 // command and fed the outcome back, so the next turn is the last one.
4774 final := strings.Contains(string(body), "tool_result")
4875
76+ // Say once whether the agent was actually told what it may spawn on: the
77+ // listing is assembled at startup and nothing else makes it visible.
78+ if strings.Contains(string(body), "Models this endpoint serves") {
79+ listedOnce.Do(func() { log.Print("the agent's system prompt carries the model listing") })
80+ }
81+
82+ // A subagent announces itself in its system prompt. It gets one turn: a
83+ // report, after a pause long enough to see it working in the sidebar.
84+ if strings.Contains(string(body), "You are a subagent") {
85+ time.Sleep(*work)
86+ answer(w, "Found it: the policy is consulted in internal/agent/loop.go:281, "+
87+ "through Agent.authorizedExecute.")
88+ return
89+ }
90+
4991 w.Header().Set("Content-Type", "text/event-stream")
5092 w.WriteHeader(http.StatusOK)
5193
@@ -61,6 +103,9 @@ func handle(w http.ResponseWriter, r *http.Request) {
61103 if final {
62104 text = "That is everything I can tell from here."
63105 }
106+ if *spawn && !final {
107+ text = "Two independent questions — I will farm them out."
108+ }
64109 send(w, "content_block_start", map[string]any{
65110 "type": "content_block_start", "index": 0,
66111 "content_block": map[string]any{"type": "text", "text": ""},
@@ -75,18 +120,23 @@ func handle(w http.ResponseWriter, r *http.Request) {
75120 send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})
76121
77122 stop := "end_turn"
78- if !final {
123+ switch {
124+ case final:
125+ case *spawn:
79126 stop = "tool_use"
80- send(w, "content_block_start", map[string]any{
81- "type": "content_block_start", "index": 1,
82- "content_block": map[string]any{"type": "tool_use", "id": "toolu_1", "name": "bash"},
127+ // One on a model that takes no effort level and one that does, so a
128+ // run exercises both halves of the answer.
129+ toolCall(w, 1, "toolu_1", "spawn_agent", map[string]string{
130+ "preset": "explore", "task": "find where the approval policy is consulted",
131+ "model": "claude-haiku-4-5", "effort": "low",
83132 })
84- args, _ := json.Marshal(map[string]string{"command": *cmd})
85- send(w, "content_block_delta", map[string]any{
86- "type": "content_block_delta", "index": 1,
87- "delta": map[string]any{"type": "input_json_delta", "partial_json": string(args)},
133+ toolCall(w, 2, "toolu_2", "spawn_agent", map[string]string{
134+ "preset": "plan", "task": "work out how subagent transcripts could survive a resume",
135+ "model": "claude-fable-5-1", "effort": "low",
88136 })
89- send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 1})
137+ default:
138+ stop = "tool_use"
139+ toolCall(w, 1, "toolu_1", "bash", map[string]string{"command": *cmd})
90140 }
91141
92142 send(w, "message_delta", map[string]any{
@@ -97,6 +147,49 @@ func handle(w http.ResponseWriter, r *http.Request) {
97147 send(w, "message_stop", map[string]any{"type": "message_stop"})
98148 }
99149
150+ // toolCall streams one tool_use block, arguments and all.
151+ func toolCall(w http.ResponseWriter, index int, id, name string, args map[string]string) {
152+ send(w, "content_block_start", map[string]any{
153+ "type": "content_block_start", "index": index,
154+ "content_block": map[string]any{"type": "tool_use", "id": id, "name": name},
155+ })
156+ raw, _ := json.Marshal(args)
157+ send(w, "content_block_delta", map[string]any{
158+ "type": "content_block_delta", "index": index,
159+ "delta": map[string]any{"type": "input_json_delta", "partial_json": string(raw)},
160+ })
161+ send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
162+ }
163+
164+ // answer streams a whole turn that is nothing but text, which is what a
165+ // subagent's report is.
166+ func answer(w http.ResponseWriter, text string) {
167+ w.Header().Set("Content-Type", "text/event-stream")
168+ w.WriteHeader(http.StatusOK)
169+ send(w, "message_start", map[string]any{
170+ "type": "message_start",
171+ "message": map[string]any{
172+ "id": "msg_mock", "model": "claude-haiku-4-5",
173+ "usage": map[string]any{"input_tokens": 900},
174+ },
175+ })
176+ send(w, "content_block_start", map[string]any{
177+ "type": "content_block_start", "index": 0,
178+ "content_block": map[string]any{"type": "text", "text": ""},
179+ })
180+ send(w, "content_block_delta", map[string]any{
181+ "type": "content_block_delta", "index": 0,
182+ "delta": map[string]any{"type": "text_delta", "text": text},
183+ })
184+ send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})
185+ send(w, "message_delta", map[string]any{
186+ "type": "message_delta",
187+ "delta": map[string]any{"stop_reason": "end_turn"},
188+ "usage": map[string]any{"output_tokens": 60},
189+ })
190+ send(w, "message_stop", map[string]any{"type": "message_stop"})
191+ }
192+
100193 func send(w http.ResponseWriter, event string, data any) {
101194 b, _ := json.Marshal(data)
102195 fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
+5−0internal/tui/theme.goView File
@@ -178,6 +178,11 @@ func applyTheme(name string) {
178178 BorderForeground(colFaint).
179179 Padding(0, 1)
180180
181+ styBar = lipgloss.NewStyle().
182+ Border(lipgloss.NormalBorder(), false, false, false, true).
183+ BorderForeground(colFaint).
184+ Padding(0, 1)
185+
181186 styModal = lipgloss.NewStyle().
182187 Border(lipgloss.RoundedBorder()).
183188 BorderForeground(colCoral).