package tui
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"harness/internal/agent"
"harness/internal/approval"
"harness/internal/llm"
"harness/internal/skills"
)
// Options wires the TUI to an already-built agent. The TUI owns no policy of
// its own: it renders what the agent reports and asks what the policy asks.
type Options struct {
Agent *agent.Agent
Policy *approval.Policy
Client *llm.Client
Root string
SessionDir string
SessionID string
ToolNames []string
Verbose bool
YOLO bool
// Version, Effort and ContextWindow are display only: they let the
// welcome block and the status bar report the run's shape rather than
// guess at it.
Version string
Effort string
ContextWindow int
// Banner is an optional first line of scrollback, used for things the CLI
// wants to say before the loop starts: a resumed session, a non-default
// endpoint.
Banner []string
// Skills are the instruction sets discovered at startup, used to resolve
// /<slug> and to answer /skills.
Skills []skills.Skill
}
type phase int
const (
phaseIdle phase = iota
phaseRunning
phaseApproving
)
// inputMaxLines caps how far the prompt box grows before it scrolls. Past
// about six lines the box is eating the transcript it exists to talk about.
const inputMaxLines = 6
type model struct {
opts Options
bridge *bridge
vp viewport.Model
input textarea.Model
spin spinner.Model
tr *transcript
phase phase
width int
height int
ready bool
// Turn bookkeeping.
turn int
started time.Time
usage llm.Usage
turns int
lastCost float64
costKnown bool
// ctxTokens is the last turn's input size — what the model actually read,
// which is the number the context gauge is about. It replaces itself each
// turn rather than accumulating, and compaction makes it drop.
ctxTokens int
// verbSeed varies the working verb per turn so two turns in a row do not
// open with the same word.
verbSeed int
// pending approvals. The agent blocks on one at a time, but a queue costs
// nothing and means a second request can never be dropped.
pending []approvalMsg
// ctx plumbing for the running turn.
parent context.Context
cancel context.CancelFunc
// interrupting records that the user asked to stop, so the status line can
// say so while the agent unwinds.
interrupting bool
quitting bool
err error
}
// pulse is the spinner: a star opening and closing rather than a rotating
// bar, which reads as thought rather than as a progress bar that is lying
// about knowing how far along it is.
var pulse = spinner.Spinner{
Frames: []string{"✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳"},
FPS: time.Second / 8,
}
// verbs are what the status line calls the wait. They rotate so a long turn
// does not look frozen even when nothing is streaming.
var verbs = []string{
"Thinking", "Pondering", "Deliberating", "Noodling", "Percolating",
"Puzzling", "Wrangling", "Simmering", "Cogitating", "Tinkering",
"Untangling", "Ruminating",
}
// newModel builds the model. Construction lives here rather than inside Run so
// tests drive the same wiring the program does; a hand-built model with a
// zero-valued textarea panics on the first resize.
func newModel(ctx context.Context, opts Options) *model {
ta := textarea.New()
ta.Placeholder = "ask for a change, or /help"
ta.Prompt = "› "
ta.ShowLineNumbers = false
ta.CharLimit = 0
ta.SetHeight(1)
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
ta.FocusedStyle.Prompt = styCoral
ta.BlurredStyle.Prompt = styFaint
ta.FocusedStyle.Placeholder = styFaint
ta.BlurredStyle.Placeholder = styFaint
ta.Focus()
// Enter submits; the model handles it before the textarea sees it.
ta.KeyMap.InsertNewline.SetKeys("ctrl+j", "alt+enter")
sp := spinner.New()
sp.Spinner = pulse
sp.Style = styCoral
m := &model{
opts: opts,
bridge: &bridge{},
input: ta,
spin: sp,
tr: newTranscript(80),
parent: ctx,
}
m.tr.setVerbose(opts.Verbose)
m.tr.addRaw(splash(opts, 80))
for _, line := range opts.Banner {
m.tr.addNotice(line)
}
if opts.YOLO {
m.tr.addNotice("-yolo — no approval prompts. Use a throwaway clone or a container.")
}
return m
}
// Run starts the program and blocks until the user exits.
func Run(ctx context.Context, opts Options) error {
m := newModel(ctx, opts)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx))
// The bridge can only learn the program after it exists; the agent does
// not touch it until the first turn starts, which is after Run.
m.bridge.p = p
opts.Agent.Observe = m.bridge.Observe
if opts.Policy != nil {
opts.Policy.Prompt = m.bridge.Prompt
}
final, err := p.Run()
if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) {
return err
}
if fm, ok := final.(*model); ok {
return fm.err
}
return nil
}
func (m *model) Init() tea.Cmd { return textarea.Blink }
// runTurn drives one prompt on its own goroutine. Events reach the UI through
// the bridge while this runs; the command's return value ends the turn.
func (m *model) runTurn(prompt string) tea.Cmd {
ctx, cancel := context.WithCancel(m.parent)
m.cancel = cancel
a := m.opts.Agent
return func() tea.Msg {
_, err := a.Run(ctx, prompt)
cancel()
if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model); saveErr != nil && err == nil {
err = fmt.Errorf("saving session: %w", saveErr)
}
u, turns := a.State().Usage()
return turnDone{err: err, usage: u, turns: turns}
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m, m.resize(msg.Width, msg.Height)
case tea.KeyMsg:
return m.onKey(msg)
case spinner.TickMsg:
if m.phase == phaseRunning {
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
// ---- agent events -------------------------------------------------
case evTurnStart:
m.turn = msg.turn
return m, nil
case evTextDelta:
m.tr.appendDelta(msg.text)
return m, m.sync()
case evText:
m.tr.add(entry{kind: entryAssistant, text: strings.TrimSpace(msg.text)})
return m, m.sync()
case evToolStart:
m.tr.startTool(msg.tool, msg.input)
return m, m.sync()
case evToolResult:
m.tr.finishTool(msg.tool, msg.result, msg.isError)
return m, m.sync()
case evUsage:
m.usage = addUsage(m.usage, msg.usage)
m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens
return m, nil
case evCompact:
m.tr.addNotice("compacted the conversation")
return m, m.sync()
case approvalMsg:
m.pending = append(m.pending, msg)
m.phase = phaseApproving
return m, m.sync()
case turnDone:
return m, m.finishTurn(msg)
}
return m, nil
}
func (m *model) resize(w, h int) tea.Cmd {
m.width, m.height = w, h
m.tr.setWidth(w)
m.input.SetWidth(m.innerWidth())
if !m.ready {
m.vp = viewport.New(w, maxInt(1, h-8))
m.ready = true
} else {
m.vp.Width = w
}
// The welcome block is laid out for a width, so it is re-rendered rather
// than wrapped: it is the one entry that owns its own lines.
if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw {
m.tr.entries[0].text = splash(m.opts, w)
m.tr.dirty = true
}
m.vp.SetContent(m.tr.render())
m.vp.GotoBottom()
return nil
}
// sync re-renders the scrollback, keeping the view pinned to the bottom unless
// the user has scrolled up to read something.
func (m *model) sync() tea.Cmd {
if !m.ready {
return nil
}
atBottom := m.vp.AtBottom()
m.vp.SetContent(m.tr.render())
if atBottom {
m.vp.GotoBottom()
}
return nil
}
func (m *model) finishTurn(msg turnDone) tea.Cmd {
m.phase = phaseIdle
m.interrupting = false
m.usage, m.turns = msg.usage, msg.turns
m.lastCost, m.costKnown = msg.usage.Cost(m.opts.Client.Model)
m.tr.closeOpen()
// Any approval still outstanding belongs to a turn that no longer exists.
for _, p := range m.pending {
p.reply <- approval.Deny
}
m.pending = nil
switch {
case msg.err == nil:
case errors.Is(msg.err, context.Canceled):
m.tr.addNotice("interrupted — the session is intact; say what to do differently")
default:
m.tr.addError(msg.err.Error())
}
m.input.Focus()
return tea.Batch(m.sync(), textarea.Blink)
}
func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.phase == phaseApproving {
return m.onApprovalKey(msg)
}
switch msg.String() {
case "ctrl+c":
// While a turn runs, ctrl-c interrupts it and leaves the session
// intact. Idle, it exits.
if m.phase == phaseRunning {
return m, m.interrupt()
}
m.quitting = true
return m, tea.Quit
case "esc":
if m.phase == phaseRunning {
return m, m.interrupt()
}
return m, nil
case "ctrl+d":
m.quitting = true
return m, tea.Quit
case "ctrl+r":
m.tr.setVerbose(!m.tr.verbose)
state := "collapsed"
if m.tr.verbose {
state = "full"
}
m.tr.addNotice("tool output: " + state)
return m, m.sync()
case "shift+tab":
return m, m.cycleMode()
case "pgup", "pgdown", "shift+up", "shift+down", "ctrl+home", "ctrl+end":
var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
return m, cmd
case "enter":
if m.phase == phaseRunning {
return m, nil // the agent is busy; queueing input would be a lie
}
line := strings.TrimSpace(m.input.Value())
if line == "" {
return m, nil
}
m.input.Reset()
m.growInput()
return m.submit(line)
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.growInput()
return m, cmd
}
func (m *model) interrupt() tea.Cmd {
if m.cancel != nil {
m.cancel()
}
m.interrupting = true
return nil
}
// cycleMode flips the approval policy between asking and accepting. It is the
// one control in the chrome that changes what the agent is allowed to do, so
// it says so in the transcript rather than only in the mode line.
func (m *model) cycleMode() tea.Cmd {
if m.opts.Policy == nil {
return nil
}
m.opts.Policy.YOLO = !m.opts.Policy.YOLO
if m.opts.Policy.YOLO {
m.tr.addNotice("accept-all on — tool calls run without asking. Use a throwaway clone or a container.")
} else {
m.tr.addNotice("accept-all off — you will be asked before anything is changed.")
}
return m.sync()
}
// growInput sizes the prompt box to what has been typed into it.
func (m *model) growInput() {
inner := m.innerWidth()
n := 0
for _, line := range strings.Split(m.input.Value(), "\n") {
w := lipgloss.Width(line) + 2 // the "› " prompt
rows := (w + inner - 1) / inner
n += maxInt(1, rows)
}
if h := minInt(maxInt(1, n), inputMaxLines); h != m.input.Height() {
m.input.SetHeight(h)
}
}
// submit handles slash commands locally and sends anything else to the agent.
func (m *model) submit(line string) (tea.Model, tea.Cmd) {
switch {
case line == "/exit", line == "/quit":
m.quitting = true
return m, tea.Quit
case line == "/help":
m.tr.addNotice(helpText)
return m, m.sync()
case line == "/clear":
m.tr.clear()
return m, m.sync()
case line == "/allowed":
allowed := m.opts.Policy.Remembered()
if len(allowed) == 0 {
m.tr.addNotice("nothing is permanently allowed this session")
} else {
m.tr.addNotice("always allowed:\n " + strings.Join(allowed, "\n "))
}
return m, m.sync()
case line == "/skills":
if len(m.opts.Skills) == 0 {
m.tr.addNotice("no skills found in ~/" + skills.Dirname + " or ./" + skills.Dirname)
} else {
var b strings.Builder
b.WriteString("skills:")
for _, s := range m.opts.Skills {
fmt.Fprintf(&b, "\n /%s (%s) — %s", s.Name, s.Source, s.Description)
}
m.tr.addNotice(b.String())
}
return m, m.sync()
case strings.HasPrefix(line, "/") && !m.isSkill(line):
m.tr.addError("unknown command " + line + " — /help lists them")
return m, m.sync()
}
m.tr.addUser(line)
m.phase = phaseRunning
m.turn = 0
m.started = time.Now()
m.verbSeed++
return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line))
}
// isSkill reports whether a slash command names a discovered skill. Matching
// here rather than sending every unrecognised slash to the model means a typo
// is caught by the harness instead of puzzled over by the agent.
func (m *model) isSkill(line string) bool {
name := strings.TrimPrefix(line, "/")
if i := strings.IndexAny(name, " \t"); i >= 0 {
name = name[:i]
}
for _, s := range m.opts.Skills {
if s.Name == name {
return true
}
}
return false
}
// ---- approvals ----------------------------------------------------------
// choice is one answer offered by the approval box. Numbering it here rather
// than in the renderer keeps the keys and the labels from drifting apart.
type choice struct {
label string
keys []string
dec approval.Decision
note string
}
func (m *model) choices(req approval.Request) []choice {
out := []choice{{label: "Yes", keys: []string{"y", "enter"}, dec: approval.Once, note: "approved once"}}
if req.Rememberable {
out = append(out, choice{
label: "Yes, and always this exact one",
keys: []string{"a"},
dec: approval.AlwaysThisCommand,
note: "always allowing this exact " + req.Tool + " this session",
})
}
out = append(out,
choice{
label: "Yes, and always this tool",
keys: []string{"t"},
dec: approval.AlwaysThisTool,
note: "always allowing tool " + req.Tool + " this session",
},
choice{label: "No, tell it to do something else", keys: []string{"n", "esc"}, dec: approval.Deny, note: "denied"},
)
return out
}
func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if len(m.pending) == 0 {
m.phase = phaseRunning
return m, nil
}
req := m.pending[0]
key := msg.String()
// ctrl-c mid-prompt denies the call and cancels the turn.
if key == "ctrl+c" {
req.reply <- approval.Deny
m.pending = m.pending[1:]
m.phase = phaseRunning
return m, tea.Batch(m.interrupt(), m.sync())
}
// "always this exact one" is not offered for an opaque command, and
// pressing its key must explain that rather than silently doing nothing.
opts := m.choices(req.req)
if key == "a" && !req.req.Rememberable {
m.tr.addError("this one cannot be remembered: its text does not determine what it runs")
return m, m.sync()
}
for i, c := range opts {
if !matches(c.keys, key) && key != fmt.Sprint(i+1) {
continue
}
req.reply <- c.dec
m.pending = m.pending[1:]
if len(m.pending) == 0 {
m.phase = phaseRunning
}
// The decision belongs to the call it gated, not to whatever the
// transcript happens to end with once the result lands.
m.tr.annotateTool(req.req.Tool, c.note)
return m, tea.Batch(m.sync(), m.spin.Tick)
}
return m, nil
}
func matches(keys []string, key string) bool {
for _, k := range keys {
if k == key {
return true
}
}
return false
}
// ---- view ---------------------------------------------------------------
const helpText = `commands: /allowed /clear /help /skills /exit
keys: enter send · ctrl+j newline · shift+tab cycle approval mode
ctrl+r toggle full tool output · pgup/pgdn scroll
esc or ctrl+c interrupt a turn · ctrl+c again to exit`
func (m *model) View() string {
if !m.ready {
return "starting…"
}
if m.quitting {
return ""
}
// The bottom chrome changes height — the prompt box grows, the approval
// box is taller than either — so the viewport is sized against what was
// actually rendered rather than against an assumed number of rows.
bottom := m.bottom()
if h := maxInt(1, m.height-lipgloss.Height(bottom)); h != m.vp.Height {
atBottom := m.vp.AtBottom()
m.vp.Height = h
if atBottom {
m.vp.GotoBottom()
}
}
return m.vp.View() + "\n" + bottom
}
func (m *model) bottom() string {
var b strings.Builder
b.WriteString(m.activity() + "\n")
if m.phase == phaseApproving && len(m.pending) > 0 {
b.WriteString(m.approvalView() + "\n")
} else {
b.WriteString(m.inputBox() + "\n")
}
b.WriteString(m.status() + "\n")
b.WriteString(m.modeLine())
return b.String()
}
// activity is the line above the prompt: what the agent is doing on the left,
// the effort setting on the right.
func (m *model) activity() string {
var left string
switch m.phase {
case phaseRunning:
verb := verbs[(m.verbSeed+int(time.Since(m.started).Seconds())/4)%len(verbs)]
if m.interrupting {
verb = "Interrupting"
}
detail := fmt.Sprintf("%ds", int(time.Since(m.started).Seconds()))
if m.turn > 0 {
detail += fmt.Sprintf(" · turn %d", m.turn)
}
if m.usage.OutputTokens > 0 {
detail += " · ↓ " + human(m.usage.OutputTokens) + " tokens"
}
left = m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+" · esc to interrupt)")
case phaseApproving:
left = styWarn.Render("● waiting for you")
}
right := ""
if m.opts.Effort != "" {
right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort")
}
return spread(left, right, m.width)
}
func (m *model) innerWidth() int { return maxInt(8, m.boxWidth()-4) }
// boxWidth keeps the prompt frame usable on a narrow terminal rather than
// letting it collapse into a border with no room inside it.
func (m *model) boxWidth() int { return maxInt(14, m.width) }
func (m *model) inputBox() string {
style := styBoxFocus
if m.phase == phaseRunning {
style = styBoxBlur
}
return style.Width(m.boxWidth() - 2).Render(m.input.View())
}
// status is the pill line under the prompt: who, which model, how full the
// context is, and what it has cost so far. The fields are separated by space
// rather than by a dot, because the pills already carry their own padding and
// a dot between them reads as a gap twice as wide as it is.
func (m *model) status() string {
parts := []string{
styName.Render(username()),
styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model)),
}
if pill, ok := m.contextPill(); ok {
parts = append(parts, pill)
}
// Cache reads are reported separately from fresh input: the gap between
// the two is the whole point of the caching this harness measures.
u := m.usage
if u.InputTokens+u.OutputTokens+u.CacheReadInputTokens > 0 {
io := fmt.Sprintf("↑ %s", human(u.InputTokens))
if u.CacheReadInputTokens > 0 {
io += fmt.Sprintf(" ⟳ %s", human(u.CacheReadInputTokens))
}
io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens))
parts = append(parts, styDim.Render(io))
if cost, known := u.Cost(m.opts.Client.Model); known {
parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", cost)))
}
}
return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width))
}
// contextPill is the context gauge: how much the model read last turn against
// the window it has, coloured by how close that is to full.
//
// It is one style over the whole pill rather than a coloured glyph inside a
// background, because a nested style ends with a reset — which drops the
// background for the rest of the pill and leaves a visible tear in it.
func (m *model) contextPill() (string, bool) {
if m.ctxTokens <= 0 {
return "", false
}
pct := m.ctxTokens * 100 / m.contextWindow()
gauge := colOK
switch {
case pct >= 80:
gauge = colErr
case pct >= 60:
gauge = colWarn
}
return styPill.Foreground(gauge).Render(fmt.Sprintf("◐ %s (%d%%)", human(m.ctxTokens), pct)), true
}
func (m *model) contextWindow() int {
if m.opts.ContextWindow > 0 {
return m.opts.ContextWindow
}
return agent.DefaultContextWindow
}
// modeLine reports what the approval policy will do next, because a mode that
// silently lets the agent write to disk has to be visible at all times.
func (m *model) modeLine() string {
if m.phase == phaseApproving {
return ""
}
if m.opts.Policy != nil && m.opts.Policy.YOLO {
return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"), maxInt(1, m.width))
}
return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle) · /help for commands"), maxInt(1, m.width))
}
func (m *model) approvalView() string {
req := m.pending[0].req
width := maxInt(20, m.width-2)
inner := width - 4
var b strings.Builder
b.WriteString(styHeading.Render(req.Tool) + styDim.Render(" wants to "+req.Summary) + "\n")
if req.Detail != "" {
b.WriteString("\n")
lines := strings.Split(strings.TrimRight(req.Detail, "\n"), "\n")
for _, line := range clip(lines, 10) {
b.WriteString(styText.Render(truncate(line, maxInt(10, inner))) + "\n")
}
if n := len(lines) - 10; n > 0 {
b.WriteString(styFaint.Render("… +"+plural(n, "more line")) + "\n")
}
}
b.WriteString("\n" + styDim.Render("Do you want to proceed?") + "\n")
for i, c := range m.choices(req) {
b.WriteString(styKey.Render(fmt.Sprint(i+1)+".") + " " + styText.Render(c.label))
if alias := c.keys[0]; alias != "enter" {
b.WriteString(styFaint.Render(" (" + alias + ")"))
}
b.WriteString("\n")
}
return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n"))
}
// spread puts left at the left margin and right at the right, and gives up on
// the right-hand side rather than overflowing when they cannot both fit.
func spread(left, right string, width int) string {
width = maxInt(1, width)
lw, rw := lipgloss.Width(left), lipgloss.Width(right)
if gap := width - lw - rw - 1; gap >= 1 {
return " " + left + strings.Repeat(" ", gap) + right
}
return truncateStyled(" "+left, width)
}
func username() string {
for _, k := range []string{"USER", "LOGNAME", "USERNAME"} {
if v := os.Getenv(k); v != "" {
return v
}
}
return "you"
}
func addUsage(a, b llm.Usage) llm.Usage {
a.InputTokens += b.InputTokens
a.OutputTokens += b.OutputTokens
a.CacheReadInputTokens += b.CacheReadInputTokens
a.CacheCreationInputTokens += b.CacheCreationInputTokens
return a
}