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