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 // / and to answer /skills. Skills []skills.Skill } 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 ) // 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 // menu is the slash-command completion list under the prompt. menu menu // effortIdx is where the marker sits while the effort slider is up. effortIdx 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) } if m.phase == phaseEffort { return m.onEffortKey(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 } } 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() 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 == "/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 == "/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)) } // 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 "" } // 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") 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. 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 }