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
}