Vectrum
Sign In
157 lines · 5.1 KB · Go
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()
	}
}