package tui import ( "context" tea "github.com/charmbracelet/bubbletea" "codey/internal/agent" "codey/internal/approval" "codey/internal/ask" "codey/internal/llm" "codey/internal/subagent" ) // The agent loop runs on its own goroutine and must not know a UI exists. It // reaches the model through two one-way hatches: // // - events go out through Program.Send, which is safe from any goroutine; // - approvals go out the same way and then block the agent on a reply // channel until the user presses a key. // // Blocking the agent goroutine is the correct behaviour, not a compromise: the // tool must not run until a human says so. The Bubble Tea update loop keeps // running throughout, so the UI stays responsive while the agent waits. // sender is the part of tea.Program the bridge needs, narrowed to an interface // so tests can drive it without starting a terminal program. type sender interface{ Send(tea.Msg) } // Event messages delivered into the Bubble Tea loop. type ( evTurnStart struct{ turn int } evTextDelta struct{ text string } evText struct{ text string } evToolStart struct{ tool, input string } evToolResult struct { tool, result string isError bool } evUsage struct{ usage llm.Usage } evCompact struct{} evNotice struct{ text string } // evSub is one subagent's event, tagged with the agent it came from. evSub struct{ ev subagent.Event } // agentActed comes back from a pause, resume or kill asked for in the // sidebar, carrying whatever the supervisor refused with. agentActed struct{ err error } // evAgents says a subagent's status moved or one finished. It carries // nothing: the model reads the supervisor, which is the one copy of the // truth, rather than a snapshot that was true when it was sent. evAgents struct{} // approvalMsg asks the user to decide. reply is buffered, so answering // never blocks the UI even if the agent's context was cancelled first. approvalMsg struct { req approval.Request reply chan<- approval.Decision } // askMsg is a question the agent stopped to ask. Same shape as an // approval and the same buffered reply, because it is the same bargain: // the agent waits on a channel and the UI stays alive. askMsg struct { req ask.Request reply chan<- ask.Response } // turnDone reports the end of one prompt, with whatever the run returned. turnDone struct { err error usage llm.Usage turns int } ) // bridge adapts the agent's callbacks to Bubble Tea messages. type bridge struct{ p sender } // Observe is the agent.Observe callback. It is called from the agent // goroutine; Send is safe there. func (b *bridge) Observe(ev agent.Event) { switch ev.Kind { case agent.EventTurnStart: b.p.Send(evTurnStart{turn: ev.Turn}) case agent.EventTextDelta: b.p.Send(evTextDelta{text: ev.Text}) case agent.EventText: b.p.Send(evText{text: ev.Text}) case agent.EventToolStart: b.p.Send(evToolStart{tool: ev.Tool, input: summarizeInput(ev.Input)}) case agent.EventToolResult: b.p.Send(evToolResult{tool: ev.Tool, result: ev.Result, isError: ev.IsError}) case agent.EventUsage: b.p.Send(evUsage{usage: ev.Usage}) case agent.EventCompact: b.p.Send(evCompact{}) case agent.EventNotice: b.p.Send(evNotice{text: ev.Text}) } } // Subagent is the subagent.Options.Observe callback, called from a child's // own goroutine. func (b *bridge) Subagent(ev subagent.Event) { b.p.Send(evSub{ev: ev}) } // AgentsChanged is the subagent.Options.Changed callback: something moved, // come and look. // // Send blocks until the update loop reads it, so this must never be reached // from inside Update — see controlSelected, which is why agent controls run // as commands. func (b *bridge) AgentsChanged() { b.p.Send(evAgents{}) } // Prompt implements approval.Prompt. It hands the request to the UI and waits. // // The context is honoured while waiting, so ctrl-c during an approval prompt // interrupts the turn instead of stranding the agent on a channel nobody will // ever read. func (b *bridge) Prompt(ctx context.Context, req approval.Request) (approval.Decision, error) { if err := ctx.Err(); err != nil { return approval.Deny, err } reply := make(chan approval.Decision, 1) b.p.Send(approvalMsg{req: req, reply: reply}) select { case d := <-reply: return d, nil case <-ctx.Done(): // Denying on cancellation is the only safe reading: the turn is going // away and nobody approved anything. return approval.Deny, ctx.Err() } } // Ask implements ask.Prompt: it puts a question on screen and waits for an // answer, honouring the context so an interrupted turn does not leave the // agent parked on a channel nobody will read. // // A cancelled question is not a dismissed one. Dismissal is a thing the user // did and the model is told about it; cancellation is the turn going away, // and the error says so. func (b *bridge) Ask(ctx context.Context, req ask.Request) (ask.Response, error) { if err := ctx.Err(); err != nil { return ask.Response{}, err } reply := make(chan ask.Response, 1) b.p.Send(askMsg{req: req, reply: reply}) select { case r := <-reply: return r, nil case <-ctx.Done(): return ask.Response{}, ctx.Err() } }