Vectrum
Sign In
100 lines · 3.0 KB · Go
package tui

import (
	"context"

	tea "github.com/charmbracelet/bubbletea"

	"harness/internal/agent"
	"harness/internal/approval"
	"harness/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{}

	// 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{})
	}
}

// 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()
	}
}