Chapter 9: Terminal UI with Bubble Tea
From fmt.Println to a Real UI
Up to now we’ve been printing to stdout. That works for one-shot prompts but falls apart the moment you want:
- A persistent input box at the bottom
- Streaming text that doesn’t fight scrollback
- An approval prompt that pauses the agent while the user thinks
- Colors, spacing, and structure that don’t look like a CI log
Bubble Tea gives us all of that with the Elm Architecture: state, messages, an Update function, a View function. If you’ve never written Elm or Redux, the mental model is “every interaction is a message; the model handles messages and produces a new model and possibly more messages.”
The hard part for us isn’t Bubble Tea itself — it’s bridging Bubble Tea’s single-threaded Update loop with our agent loop’s goroutine and channel.
Installing the Charm Stack
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss
go get github.com/charmbracelet/bubbles/textinput
Three packages:
bubbletea— The runtime and theModel/Update/Viewinterfaces.lipgloss— Style definitions: colors, padding, borders.bubbles/textinput— A reusable text-input widget so we don’t reinvent cursor handling.
Styles
Create ui/styles.go:
package ui
import "github.com/charmbracelet/lipgloss"
var (
userStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
assistantStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
toolCallStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("13"))
toolResultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
approvalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).
Bold(true).
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
)
The numbers are ANSI palette indices. They render reasonably on every terminal without requiring true color support.
The Model
Bubble Tea calls the application state a Model. Ours holds the conversation transcript, the input field, the current streaming buffer, and the pending approval (if any).
Create ui/app.go:
package ui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/yourname/agents-go/agent"
"github.com/yourname/agents-go/api"
)
type lineKind int
const (
lineUser lineKind = iota
lineAssistant
lineToolCall
lineToolResult
lineError
)
type line struct {
kind lineKind
text string
}
// pendingApproval holds a tool call that needs user confirmation.
type pendingApproval struct {
call agent.ToolCall
resp chan bool
}
// Model is the Bubble Tea application state.
type Model struct {
agent *agent.Agent
history []api.InputItem
lines []line
input textinput.Model
streaming strings.Builder
events chan agent.Event
approval chan pendingApproval
pending *pendingApproval
busy bool
quit bool
}
// NewModel constructs the UI model. The system prompt is held by the agent
// itself (via the Responses API `instructions` field), so the UI only tracks
// the input array.
func NewModel(a *agent.Agent) Model {
ti := textinput.New()
ti.Placeholder = "Ask the agent something..."
ti.Focus()
ti.CharLimit = 4096
ti.Width = 80
return Model{
agent: a,
input: ti,
approval: make(chan pendingApproval),
}
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
A few things worth pointing at:
linesis the rendered transcript. We don’t try to re-renderhistoryfrom scratch each frame; we keep a parallel slice oflinerecords that already know how they should be styled.streamingis a separate buffer for the in-progress assistant turn. When the model finishes streaming, we flush it intolinesas one assistant entry.approvalis an unbuffered channel. The agent loop sends apendingApprovaland blocks onresp. The UI receives it, renders the prompt, and unblocks the agent only after the user pressesyorn.
Bridging the Agent Loop to Bubble Tea
Bubble Tea’s Update function is single-threaded. To get events from a channel into Update, we wrap each receive in a tea.Cmd that returns a tea.Msg.
Add to ui/app.go:
type agentEventMsg struct{ ev agent.Event }
type agentDoneMsg struct{}
type approvalRequestMsg struct{ pending pendingApproval }
func waitForEvent(events <-chan agent.Event) tea.Cmd {
return func() tea.Msg {
ev, ok := <-events
if !ok {
return agentDoneMsg{}
}
return agentEventMsg{ev: ev}
}
}
func waitForApproval(ch <-chan pendingApproval) tea.Cmd {
return func() tea.Msg {
p, ok := <-ch
if !ok {
return nil
}
return approvalRequestMsg{pending: p}
}
}
Each tea.Cmd is a function Bubble Tea runs on a goroutine of its own. When the function returns a message, Bubble Tea delivers it to Update. We chain them: every time we handle an event, we issue another waitForEvent so the next event lands as a new message.
Approval-Gating the Agent
The agent loop in Chapter 4 ran every tool unconditionally. We need to teach it to check RequiresApproval and ask first. Add a new method to agent/run.go:
// RunWithApproval is like Run but consults askApproval before executing any
// tool whose RequiresApproval returns true.
func (a *Agent) RunWithApproval(
ctx context.Context,
history []api.InputItem,
askApproval func(ToolCall) bool,
) <-chan Event {
events := make(chan Event)
go func() {
defer close(events)
input := append([]api.InputItem(nil), history...)
for {
// ... same compaction + streaming code as Run ...
// After collecting toolCalls from response.completed:
for _, tc := range toolCalls {
events <- Event{Kind: EventToolCall, ToolCall: tc}
if a.registry.RequiresApproval(tc.Name) {
if !askApproval(tc) {
result := "User denied this tool call."
events <- Event{Kind: EventToolResult, ToolCall: tc, Result: result}
input = append(input, api.NewFunctionCallOutput(tc.CallID, result))
continue
}
}
result, err := a.registry.Execute(tc.Name, json.RawMessage(tc.Arguments))
if err != nil {
result = fmt.Sprintf("Error: %v", err)
}
events <- Event{Kind: EventToolResult, ToolCall: tc, Result: result}
input = append(input, api.NewFunctionCallOutput(tc.CallID, result))
}
}
}()
return events
}
(For brevity I’m showing only the diff against Run. In your code, copy Run to RunWithApproval and add the RequiresApproval check.)
The askApproval callback is the boundary between the agent goroutine and the UI. It takes a ToolCall, blocks until the user decides, and returns true to run or false to deny. The UI implements it with the approval channel.
The Update Function
This is the meatiest function in the chapter. It handles three kinds of messages: keys, agent events, and approval requests.
Add to ui/app.go:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case agentEventMsg:
return m.handleAgentEvent(msg.ev)
case agentDoneMsg:
m.busy = false
return m, nil
case approvalRequestMsg:
m.pending = &msg.pending
return m, waitForApproval(m.approval)
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Approval prompt takes precedence over normal input.
if m.pending != nil {
switch msg.String() {
case "y", "Y":
m.pending.resp <- true
m.pending = nil
return m, nil
case "n", "N", "esc":
m.pending.resp <- false
m.pending = nil
return m, nil
}
return m, nil
}
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
m.quit = true
return m, tea.Quit
case tea.KeyEnter:
if m.busy {
return m, nil
}
text := strings.TrimSpace(m.input.Value())
if text == "" {
return m, nil
}
m.input.SetValue("")
m.lines = append(m.lines, line{kind: lineUser, text: text})
m.history = append(m.history, api.NewUserMessage(text))
m.busy = true
// Note: m.history is the []api.InputItem accumulated across turns.
ctx := context.Background()
m.events = m.agent.RunWithApproval(ctx, m.history, m.askApproval)
return m, tea.Batch(waitForEvent(m.events), waitForApproval(m.approval))
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m Model) handleAgentEvent(ev agent.Event) (tea.Model, tea.Cmd) {
switch ev.Kind {
case agent.EventTextDelta:
m.streaming.WriteString(ev.Text)
case agent.EventToolCall:
if m.streaming.Len() > 0 {
m.lines = append(m.lines, line{kind: lineAssistant, text: m.streaming.String()})
m.streaming.Reset()
}
m.lines = append(m.lines, line{
kind: lineToolCall,
text: fmt.Sprintf("%s(%s)", ev.ToolCall.Name, ev.ToolCall.Arguments),
})
case agent.EventToolResult:
preview := ev.Result
if len(preview) > 200 {
preview = preview[:200] + "..."
}
m.lines = append(m.lines, line{kind: lineToolResult, text: preview})
case agent.EventDone:
if m.streaming.Len() > 0 {
m.lines = append(m.lines, line{kind: lineAssistant, text: m.streaming.String()})
m.streaming.Reset()
}
m.busy = false
return m, nil
case agent.EventError:
m.lines = append(m.lines, line{kind: lineError, text: ev.Err.Error()})
m.busy = false
return m, nil
}
return m, waitForEvent(m.events)
}
// askApproval is the callback the agent loop calls when a destructive tool
// fires. It blocks until the UI decides.
func (m *Model) askApproval(tc agent.ToolCall) bool {
resp := make(chan bool, 1)
m.approval <- pendingApproval{call: tc, resp: resp}
return <-resp
}
The control flow is the part that’s worth re-reading:
- User presses Enter → we kick off the agent and issue two waiting commands at once: one for events, one for approval requests.
- Each event arrives, we update state, and we re-issue
waitForEvent. The approval waiter is still parked. - If the loop hits a destructive tool, the agent goroutine sends an approval request and blocks. The waiter unblocks and a
approvalRequestMsglands inUpdate. We stash it inm.pending. - The view shows the prompt; the next key press resolves it.
- We send the result back on
resp, the agent goroutine resumes, and events flow again.
tea.Batch running both waiters in parallel is what makes the approval prompt asynchronous to the event stream. Without it, the UI would have to choose to wait for one thing at a time.
The View Function
Rendering is straightforward — walk the lines, style each kind, then append the streaming buffer and the input box.
Add to ui/app.go:
func (m Model) View() string {
if m.quit {
return ""
}
var sb strings.Builder
for _, l := range m.lines {
sb.WriteString(renderLine(l))
sb.WriteByte('\n')
}
if m.streaming.Len() > 0 {
sb.WriteString(assistantStyle.Render("> " + m.streaming.String()))
sb.WriteByte('\n')
}
if m.pending != nil {
sb.WriteString(approvalStyle.Render(fmt.Sprintf(
"Approve %s(%s)? [y/N]",
m.pending.call.Name,
m.pending.call.Arguments,
)))
sb.WriteByte('\n')
}
sb.WriteString(m.input.View())
return sb.String()
}
func renderLine(l line) string {
switch l.kind {
case lineUser:
return userStyle.Render("you> ") + l.text
case lineAssistant:
return assistantStyle.Render("> ") + l.text
case lineToolCall:
return toolCallStyle.Render("[tool] ") + l.text
case lineToolResult:
return toolResultStyle.Render("[result] ") + l.text
case lineError:
return errorStyle.Render("[error] ") + l.text
}
return l.text
}
This is naive — it renders the whole transcript on every frame instead of using a scrolling viewport. For a real terminal app you’d reach for bubbles/viewport. For learning purposes, the naive version makes the data flow obvious.
Wiring main.go
Replace main.go with the UI version:
package main
import (
"log"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/joho/godotenv"
"github.com/yourname/agents-go/agent"
"github.com/yourname/agents-go/api"
"github.com/yourname/agents-go/tools"
"github.com/yourname/agents-go/ui"
)
func main() {
_ = godotenv.Load()
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
log.Fatal("OPENAI_API_KEY must be set")
}
client := api.NewClient(apiKey)
registry := agent.NewRegistry()
registry.Register(tools.ReadFile{})
registry.Register(tools.ListFiles{})
registry.Register(tools.WriteFile{})
registry.Register(tools.EditFile{})
registry.Register(tools.DeleteFile{})
registry.Register(tools.NewWebSearch())
registry.Register(tools.Shell{})
registry.Register(tools.RunCode{})
a := agent.NewAgent(client, registry)
model := ui.NewModel(a)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("ui: %v", err)
}
}
tea.WithAltScreen flips the terminal into alt-screen mode (the same mode vim and less use), giving us a clean canvas that’s restored on exit.
Run it:
go run .
You should see the input box at the bottom of an empty screen. Type a request, press Enter, watch the agent stream its way through tool calls. When it tries to write a file, the approval prompt pops up and the loop pauses until you decide.
The Concurrency Story, Reviewed
Three goroutines are running together:
- The Bubble Tea event loop — Owns the model. Single-threaded. Handles
UpdateandView. - Bubble Tea’s command runners — Run our
waitForEventandwaitForApprovalcmds, each on their own goroutine, and ferry messages back to the event loop. - The agent goroutine — Runs streaming and tool execution. Sends
Events on its channel. Blocks on the approval channel when it needs the user.
They communicate exclusively through channels. No mutexes, no shared mutable state. This is the Go concurrency story working exactly as advertised: each goroutine has one job, and the channels make hand-offs explicit.
Summary
In this chapter you:
- Learned the Elm Architecture as Bubble Tea expresses it
- Bridged the agent’s
Eventchannel to Bubble Tea viatea.Cmdwaiters - Built an approval flow with an unbuffered channel that blocks the agent until the user decides
- Rendered a styled transcript with
lipgloss - Ran the whole thing as a real terminal application
One chapter to go: hardening the agent for use by people who aren’t you.