Vectrum
Sign In
1055 lines · 30.5 KB · Go
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/config"
	"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

	// Config is what was loaded from disk, and FirstRun says there was nothing
	// to load — which is what opens the setup wizard unprompted.
	Config   config.Config
	FirstRun bool
	// SaveConfig persists a finished wizard's answers. Applying them is the
	// TUI's own job — it is the same work /model and /effort already do — so
	// all that is left for the caller is the file, which is the one part the
	// TUI has no business knowing about.
	SaveConfig func(config.Config) error
}

type phase int

const (
	phaseIdle phase = iota
	phaseRunning
	phaseApproving
	// phaseEffort is the effort slider: the prompt is gone until a level is
	// picked or the slider is dismissed.
	phaseEffort
	// phaseModel is the model picker, which starts on a wait: the list comes
	// from the endpoint.
	phaseModel
)

// 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

// defaultPlaceholder is what the prompt box says when it is pointed at the
// model, which the local modes replace with their own.
const defaultPlaceholder = "ask for a change, /help, ! for a shell"

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
	// cost is the running bill, accumulated as usage is reported and priced
	// at the model in force at that moment: a session that switched models
	// was not all billed at the model it happens to end on. priced is what
	// has already been counted, so a turn's totals — which on a resumed
	// session include history this process never saw — are charged once.
	cost      float64
	costKnown bool
	priced    llm.Usage
	// 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

	// menu is the slash-command completion list under the prompt.
	menu menu

	// local is which interpreter the prompt box is pointed at: off, "!" for
	// the shell, "$" for python. running is the command one of them started,
	// or nil.
	local   localMode
	running *localRun

	// effortIdx is where the marker sits while the effort slider is up.
	effortIdx int

	// The model picker: what the endpoint offered, where the cursor is, and
	// whether we are still waiting to be told.
	models        []llm.Model
	modelIdx      int
	modelsLoading bool

	// 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

	// wiz is the setup wizard. While it is non-nil it owns the screen and
	// every key, and this model does nothing but hold its place.
	wiz *wizard

	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 {
	// The palette has to be in force before the first style is read, which is
	// the textarea's below. Everything afterwards reads the theme through the
	// package-level styles, so this is the only place it has to happen.
	applyTheme(resolveTheme(opts.Config))

	ta := textarea.New()
	ta.Placeholder = defaultPlaceholder
	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 {
	if m.opts.FirstRun {
		return tea.Batch(textarea.Blink, m.openWizard())
	}
	return textarea.Blink
}

// openWizard hands the screen to the setup wizard. The chat model keeps its
// state untouched underneath: nothing about a running turn is disturbed,
// which is why /setup is safe to answer at any time the agent is idle.
func (m *model) openWizard() tea.Cmd {
	m.wiz = newWizard(m.opts.Client, m.opts.Config)
	cmds := []tea.Cmd{m.wiz.Init()}
	if m.ready {
		// The wizard has never seen a resize, so it is told the size the
		// model already knows rather than rendering into a zero-sized screen
		// until the terminal happens to send another one.
		cmds = append(cmds, func() tea.Msg {
			return tea.WindowSizeMsg{Width: m.width, Height: m.height}
		})
	}
	return tea.Batch(cmds...)
}

// closeWizard puts the chat back on screen, applying the answers if there
// were any. A cancelled wizard changes nothing at all.
//
// Applying goes through the same setters /model and /effort use, so a setting
// chosen in the wizard and the same setting chosen from the prompt cannot come
// out different. What the wizard adds is one summary line instead of their
// three separate notices — the done screen has already shown the detail.
func (m *model) closeWizard() tea.Cmd {
	w := m.wiz
	m.wiz = nil

	if w.result == nil {
		m.tr.addNotice("setup cancelled — nothing was changed")
		m.refreshSplash()
		m.input.Focus()
		return tea.Batch(m.sync(), textarea.Blink)
	}

	cfg := *w.result
	m.opts.Config = cfg
	m.useModel(cfg.Model)
	m.useEffort(cfg.Effort)
	if m.opts.Policy != nil {
		m.opts.Policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll
	}

	note := fmt.Sprintf("setup: %s · effort %s · approvals %s · %s theme — from the next turn on",
		prettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme)
	if m.opts.SaveConfig != nil {
		if err := m.opts.SaveConfig(cfg); err != nil {
			// The settings are already in force; only the file failed. Saying
			// so beats a wizard that looks like it did nothing.
			note += "\nthese are in force for this session, but saving them failed: " + err.Error()
		}
	}
	m.tr.addNotice(note)
	if m.opts.Policy != nil && m.opts.Policy.YOLO {
		m.tr.addNotice("accept-all is on — tool calls run without asking.")
	}

	// Every entry was laid out in the old palette, and the welcome block names
	// a model that may have just changed.
	m.refreshSplash()
	m.input.Focus()
	return tea.Batch(m.sync(), 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) {
	// The wizard owns the screen while it is open. A resize still reaches the
	// chat model underneath, so that what is revealed when the wizard closes
	// is laid out for the terminal as it is now.
	if m.wiz != nil {
		var cmds []tea.Cmd
		if size, ok := msg.(tea.WindowSizeMsg); ok {
			cmds = append(cmds, m.resize(size.Width, size.Height))
		}
		cmds = append(cmds, m.wiz.Update(msg))
		if m.wiz.result != nil || m.wiz.cancelled {
			cmds = append(cmds, m.closeWizard())
		}
		return m, tea.Batch(cmds...)
	}

	switch msg := msg.(type) {

	case tea.WindowSizeMsg:
		return m, m.resize(msg.Width, msg.Height)

	case tea.KeyMsg:
		return m.onKey(msg)

	case modelsMsg:
		return m, m.onModels(msg)

	case spinner.TickMsg:
		if m.phase == phaseRunning || m.modelsLoading || m.running != nil {
			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.addCost(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)

	case localDone:
		return m, m.finishLocal(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
	}
	m.refreshSplash()
	m.vp.SetContent(m.tr.render())
	m.vp.GotoBottom()
	return nil
}

// refreshSplash re-renders the welcome block. It is the one entry that owns
// its own lines — laid out for a width, and naming the model and the endpoint
// — so when any of those change it is rebuilt rather than wrapped or left to
// describe a session that has moved on.
func (m *model) refreshSplash() {
	if len(m.tr.entries) == 0 || m.tr.entries[0].kind != entryRaw {
		return
	}
	width := m.width
	if width <= 0 {
		width = 80
	}
	m.tr.entries[0].text = splash(m.opts, width)
	m.tr.dirty = true
}

// 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.addCost(subUsage(msg.usage, m.priced))
	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)
	}
	if m.phase == phaseEffort {
		return m.onEffortKey(msg)
	}
	if m.phase == phaseModel {
		return m.onModelKey(msg)
	}

	// An open menu owns the keys that drive it — including enter and esc,
	// which mean something else the rest of the time.
	if m.menuOpen() {
		if cmd, took := m.onMenuKey(msg); took {
			return m, cmd
		}
	}

	// The local-command modes own their opening marker, and once one is open,
	// the keys that run and leave it.
	if cmd, took := m.onLocalKey(msg); took {
		return m, cmd
	}

	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()
		}
		if m.running != nil {
			return m, m.interruptLocal()
		}
		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
		}
		if m.running != nil {
			// A local command is still running and its output is the next
			// thing to reach the model. Sending now would put the message
			// ahead of the output it is almost certainly about, so the text
			// is held in the box rather than dropped.
			return m, nil
		}
		line := strings.TrimSpace(m.input.Value())
		if line == "" {
			return m, nil
		}
		m.input.Reset()
		m.growInput()
		m.closeMenu()
		return m.submit(line)
	}

	var cmd tea.Cmd
	m.input, cmd = m.input.Update(msg)
	m.growInput()
	m.refreshMenu()
	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(m.helpText())
		return m, m.sync()

	case line == "/model" || strings.HasPrefix(line, "/model "):
		return m, m.setModel(strings.TrimSpace(strings.TrimPrefix(line, "/model")))

	case line == "/effort" || strings.HasPrefix(line, "/effort "):
		return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))

	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 == "/setup":
		if m.phase == phaseRunning {
			m.tr.addError("/setup would change the model mid-turn — interrupt first")
			return m, m.sync()
		}
		return m, m.openWizard()

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

// setModel answers /model. Bare, it asks the endpoint what it serves and
// opens the picker on the answer; with an id, it switches straight away,
// which also works on an endpoint that does not list its models.
func (m *model) setModel(id string) tea.Cmd {
	if id == "" {
		return m.openModelPicker()
	}
	m.applyModel(id)
	return m.sync()
}

// setEffort answers /effort. Bare, it opens the slider; with a level, it
// applies it directly, which is the fast path for anyone who knows what they
// want and can type it.
func (m *model) setEffort(level string) tea.Cmd {
	switch {
	case level == "":
		return m.openEffort()
	case !llm.ValidEffort(level):
		m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", "))
	default:
		m.applyEffort(level)
	}
	return m.sync()
}

// 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 ---------------------------------------------------------------

func (m *model) View() string {
	if !m.ready {
		return "starting…"
	}
	if m.quitting {
		return ""
	}
	if m.wiz != nil {
		return m.wiz.View()
	}

	// 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")
	switch {
	case m.phase == phaseApproving && len(m.pending) > 0:
		b.WriteString(m.approvalView() + "\n")
	case m.phase == phaseEffort:
		b.WriteString(m.effortView() + "\n")
	case m.phase == phaseModel:
		b.WriteString(m.modelView() + "\n")
	default:
		b.WriteString(m.inputBox() + "\n")
		if v := m.menuView(); v != "" {
			b.WriteString(v + "\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. The pill names /effort rather than /setup:
// it reports the level this session is running at, and the command that
// changes that is the one worth naming beside it.
func (m *model) activity() string {
	if m.running != nil {
		return spread(m.localActivity(), "", m.width)
	}

	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
	switch {
	case m.phase == phaseRunning:
		style = styBoxBlur
	case m.local != localOff:
		// The frame is the always-present chrome, so it is where a mode that
		// changes what enter does has to be visible.
		style = styBoxFocus.BorderForeground(localStyle(m.local).GetForeground())
	}
	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 m.costKnown {
			parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.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"
}

// addCost prices one piece of usage at the model in force and adds it to the
// running total, recording that those tokens have now been paid for.
func (m *model) addCost(u llm.Usage) {
	if u == (llm.Usage{}) {
		return
	}
	m.priced = addUsage(m.priced, u)
	if c, known := u.Cost(m.opts.Client.Model()); known {
		m.cost += c
		m.costKnown = true
	}
}

// subUsage is a minus b, floored at zero per field: totals only ever grow, and
// a negative remainder would mean charging backwards.
func subUsage(a, b llm.Usage) llm.Usage {
	return llm.Usage{
		InputTokens:              maxInt(0, a.InputTokens-b.InputTokens),
		OutputTokens:             maxInt(0, a.OutputTokens-b.OutputTokens),
		CacheReadInputTokens:     maxInt(0, a.CacheReadInputTokens-b.CacheReadInputTokens),
		CacheCreationInputTokens: maxInt(0, a.CacheCreationInputTokens-b.CacheCreationInputTokens),
	}
}

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
}