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