Vectrum
Sign In
1969 lines · 66.4 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"

	"github.com/johannSo/codey/internal/agent"
	"github.com/johannSo/codey/internal/approval"
	"github.com/johannSo/codey/internal/ask"
	"github.com/johannSo/codey/internal/attach"
	"github.com/johannSo/codey/internal/config"
	"github.com/johannSo/codey/internal/llm"
	"github.com/johannSo/codey/internal/skills"
	"github.com/johannSo/codey/internal/subagent"
	"github.com/johannSo/codey/internal/tools"
)

// 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
	// Asker is what the ask_user tool reaches the user through. Nil means the
	// session has no such tool, and nothing here will try to draw a question.
	Asker  *ask.Asker
	Client *llm.Client
	// Root is where the work happens: what the tools are jailed to and what
	// a shell command runs in. /worktree moves it into a worktree, which is
	// why it is not also where state is kept.
	Root string
	// HomeRoot is the checkout Codey was started in, and never moves. The
	// sessions and the event log stay there even after the work has moved
	// elsewhere, so one directory holds the whole history of what was done
	// from it rather than a trail scattered across worktrees.
	HomeRoot string
	// Worktree is the tree the work has moved into, empty until /worktree
	// moves it. It is saved with the session so reopening one puts the work
	// back where it was.
	Worktree   string
	SessionDir string
	SessionID  string
	ToolNames  []string
	Verbose    bool
	YOLO       bool
	// NewTools rebuilds the whole tool set against a root — subagent tools
	// included — and re-points the supervisor, so children spawned afterwards
	// land where the parent now is. /worktree needs it because each tool
	// captures the root it was built with: moving the work means building the
	// tools again, not setting a field. Nil disables /worktree, which is what
	// a test that does not wire it gets.
	NewTools func(root string) *tools.Registry
	// SystemPrefix is the identity block an OAuth session must send ahead of
	// everything else. /worktree's naming call is built by hand rather than
	// through the agent, so it needs its own copy.
	SystemPrefix string
	// 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
	// Agents is the subagent supervisor, or nil where there is none: the
	// sidebar simply never opens, and no spawn tool was registered either.
	Agents Agents

	// 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
	// WebSearch is the mode this process is actually running with, resolved at
	// startup and fixed: the tool set and the request's hosted tool definitions
	// were both built from it before the first turn.
	//
	// It is deliberately not the same thing as Config.WebSearch. The config
	// says what was asked for; this says what was delivered, and they differ
	// when -no-web was passed, when the search command turned out not to be
	// installed, or when the wizard has just written an answer this process
	// cannot adopt. Screens compare against this one, so that "takes effect
	// next start" is said exactly when it is true.
	WebSearch string
	// 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

	// HerdrPresent says this machine has herdr on it, which is the only
	// reason to ask anyone about herdr's skill. The check is the caller's
	// because it is a question about the machine, not about the screen.
	HerdrPresent bool
	// InstallHerdrSkill fetches that skill and writes it, returning the path.
	// Nil means the wizard does not offer it however HerdrPresent reads — the
	// same shape as SaveConfig, for the same reason: reaching the network and
	// writing to the home directory is not the TUI's business.
	InstallHerdrSkill func(context.Context) (string, error)

	// ClaudeSkills is how many skills Claude Code has on this machine, which
	// is what decides whether the wizard asks about them and what the question
	// says. Zero means the question is not asked. Counted by the caller, like
	// HerdrPresent, because it is a fact about the disk.
	ClaudeSkills int
	// CopyClaudeSkills copies them into ~/.agents/skills, returning how many
	// were, when the wizard's answer is "copy". Nil means the answer is saved
	// but nothing is copied, which is the same shape as InstallHerdrSkill and
	// for the same reason: writing to the home directory is the caller's.
	CopyClaudeSkills func() (int, error)
	// ClaudeSkillsMode is the mode this session actually started under, which
	// is not always what the config says: -no-skills leaves it off whatever is
	// written down. The settings panel needs the truth to know when a change
	// is one the next start will act on rather than this one.
	ClaudeSkillsMode string

	// Diagnostics is whether a file the agent edits is actually being checked
	// in this session. Like WebSearch it is what was delivered rather than
	// what was asked for: a machine with no language server and no linter
	// installed for anything in the tree has diagnostics off however the
	// config reads.
	Diagnostics string

	// NewSession builds a second conversation for a new tab, rooted at the
	// directory it is given. The TUI cannot build one itself: an agent is a
	// client, a tool set, a state and a policy, and which of those a run is
	// entitled to was decided by the flags and the config before the screen
	// existed. Nil disables the whole tab strip, which is what a test that
	// does not wire it gets, and what the single-session case has always been.
	NewSession func(root string) (SessionSpec, error)
}

// SessionSpec is one fresh conversation: the pieces a tab needs that only the
// caller can build. Each tab owns its own of every one of them, so two tabs
// share nothing but the client and the screen — a turn in one cannot be
// interrupted, approved or compacted by what happens in another.
type SessionSpec struct {
	Agent  *agent.Agent
	Policy *approval.Policy
	// SessionID is the name this conversation will be saved under. It must not
	// be one already in use: two tabs writing the same file would each save
	// half a history over the other's.
	SessionID string
	ToolNames []string
	NewTools  func(root string) *tools.Registry
	// Agents is this tab's own subagent supervisor, or nil where the run has
	// no subagents at all.
	Agents Agents
	// Close releases what the session holds — its supervisor above all — when
	// the tab is closed. Nil when there is nothing to release.
	Close func()
}

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
	// phaseSettings is the settings panel, where the saved defaults are
	// changed in place.
	phaseSettings
	// phaseAsking is a question the agent stopped to ask. Like an approval it
	// is the agent waiting on a human rather than the other way round.
	phaseAsking
	// phaseSessions is the session chooser: the saved conversations for this
	// working root, one of which replaces the one on screen.
	phaseSessions
	// phaseWorktrees is the worktree chooser behind /worktree resume: the
	// trees this repository has, narrowed by typing, one of which the work
	// moves into with a new chat in it.
	phaseWorktrees
)

// 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 · / commands · ! shell · ↑ earlier prompts"

// placeholders are the shorter ways to say the same thing, longest first. The
// box is a line of advertising for keys nothing else on screen mentions, so a
// narrow terminal loses whole clauses rather than the ends of words.
var placeholders = []string{
	defaultPlaceholder,
	"ask for a change · / commands · ↑ earlier prompts",
	"ask for a change · / commands · ↑ history",
	"ask for a change · /help",
	"ask for a change",
}

// placeholderFor is the longest hint that fits in width cells.
func placeholderFor(width int) string {
	for _, p := range placeholders {
		if lipgloss.Width(p) <= width {
			return p
		}
	}
	return placeholders[len(placeholders)-1]
}

type model struct {
	opts   Options
	bridge *bridge

	// id is which tab this conversation is, as handed out by the tab strip.
	// Everything that reaches the UI from somewhere else — an agent event, a
	// finished turn, a command that went off to the network — is tagged with
	// it on the way out, so that work in a tab nobody is looking at lands in
	// its own conversation instead of the one on screen. Zero means there is
	// no strip: a model driven on its own, which is what the tests do and what
	// a session with a single tab amounts to.
	id int
	// title is what the tab strip calls this conversation: the first thing
	// asked of it, which is what someone scanning a row of tabs is looking
	// for. Empty until something is asked.
	title string

	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

	// hist is what has been typed into this checkout's prompt before, which
	// the up arrow walks back through. It is shared with nothing: a tab keeps
	// its own walk, and the file underneath is appended to rather than
	// rewritten so two tabs remember each other's lines at the next start.
	hist *history

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

	// The settings panel. setIdx is the row under the cursor and setTouched
	// is which rows were actually changed, which is what gets written back —
	// see settingsConfig. setFrom records that the model picker was opened
	// from the panel, so it knows to return there, and setModelWas is the
	// model the panel opened on, which is how a trip through the picker is
	// noticed without the two having to agree on an order. setWeb is the
	// web-search mode the panel is showing, held here rather than read back
	// off the session because it is the one setting the session cannot change:
	// the tool set is built at startup, so cycling that row edits what will be
	// saved instead of pretending to take effect under a running agent.
	// setClaude and setDiag are held for the same reason: which skills were
	// read and which checkers are attached to the edit tool were both decided
	// before the first prompt.
	setIdx      settingRow
	setTouched  [numSettingRows]bool
	setFrom     bool
	setModelWas string
	setWeb      string
	setClaude   string
	setDiag     string

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

	// The session chooser: the saved conversations for this root, and where
	// the cursor is in them. Both are dropped when it closes.
	sessions   []agent.SessionInfo
	sessionIdx int

	// The worktree chooser: every tree of this repository, what has been typed
	// to narrow them, what is left after that, and where the cursor is in it.
	// The cursor indexes the narrowed list, which is the one on screen.
	worktrees      []worktreeInfo
	worktreeShown  []worktreeInfo
	worktreeFilter string
	worktreeIdx    int

	// The agents sidebar: whether it is open, what it last drew, and which
	// agent's thread has taken over the main pane.
	bar     sidebar
	viewing string
	// subTr is one scrollback per subagent, so a thread you open reads the
	// way your own does.
	subTr map[string]*transcript
	// pendingReports are finished agents whose reports have not gone up yet,
	// because a turn was running or the prompt had text in it.
	pendingReports []subagent.Info
	// pill is where the agents pill was last drawn inside the status line, so
	// a click on it can be told from a click on the rest of that line.
	pill rowHit

	// worktreeing is a /worktree in flight: a naming call and a script, with
	// no turn behind them. Like compacting, it owns the spinner and the
	// interrupt key while it lasts.
	worktreeing bool
	// compacting is a /compact in flight: a model call with no turn behind it,
	// which still wants the spinner and the interrupt key.
	compacting 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 — and with
	// subagents there can genuinely be several, from different agents.
	pending []approvalMsg
	// asking is the queue of questions the agent has stopped to ask, the same
	// shape and for the same reasons as pending. Each carries its own
	// half-finished answers, so it is state rather than a bare message.
	asking []*askState
	// beforeModal is what the session was doing when the first box went up —
	// an approval or a question. Neither implies a turn in flight: a subagent
	// can ask while you are idle, and answering has to put you back where you
	// were.
	beforeModal phase

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

	// The mouse. mouseOn is whether Codey is asking the terminal for
	// mouse reporting at all: it costs the terminal's own text selection, so
	// /mouse gives it back. sel is the selection that replaces it, copied is
	// what the chrome says about the last copy, and the three click fields
	// are what turns a run of presses on one cell into a word or a line.
	mouseOn   bool
	sel       selection
	copied    string
	clicks    int
	clickAt   cell
	clickTime time.Time
	// copyFn is how text leaves Codey. It is a field so a test can
	// watch what was copied without a clipboard, or a terminal, being there.
	copyFn func(string) error

	// imgs are the pictures pasted into the prompt being typed, each standing
	// in the box as [image 1]. grabFn is how one is fetched, a field for the
	// same reason copyFn is: no test should need a desktop.
	imgs   attach.Set
	grabFn func() (attach.Image, error)
	textFn func() (string, error)

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

	// A session whose work has not moved keeps its state where the work is,
	// which is the ordinary case and the only one there was before /worktree.
	if opts.HomeRoot == "" {
		opts.HomeRoot = opts.Root
	}

	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,
		mouseOn: true,
		copyFn:  copyToClipboard,
		// The history belongs to the checkout Codey was started in, not to
		// the directory the work has since moved to: a worktree is a place
		// this conversation is happening, and the commands you type in it are
		// the same commands you will want back in the next one.
		hist: newHistory(opts.HomeRoot),
	}
	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 {
	t := newTabs(ctx, opts)
	// Cell motion rather than all motion: the drag is what has to be
	// followed, and reporting every pixel of idle movement would put a
	// message on the wire for a hand resting on the desk.
	p := tea.NewProgram(t, tea.WithAltScreen(), tea.WithMouseCellMotion(), 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.
	t.attach(p)

	final, err := p.Run()
	if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) {
		return err
	}
	if ft, ok := final.(*tabs); ok {
		return ft.err()
	}
	return nil
}

// wire points the agent, the policy and the supervisor at this model's bridge.
// It is one method rather than four lines at the top of Run because a tab
// opened later has to be connected the same way the first one was, and two
// copies of that list would be one copy too many.
func (m *model) wire(p sender) {
	m.bridge.p = p
	if m.opts.Agent != nil {
		m.opts.Agent.Observe = m.bridge.Observe
	}
	if m.opts.Policy != nil {
		m.opts.Policy.Prompt = m.bridge.Prompt
	}
	if m.opts.Asker != nil {
		// Until this line the tool would have reached whatever the line front
		// end installed, which is reading from a stdin the alt screen has just
		// taken over.
		m.opts.Asker.Use(m.bridge.Ask)
	}
	if m.opts.Agents != nil {
		m.opts.Agents.Subscribe(m.bridge.Subagent, m.bridge.AgentsChanged)
	}
}

// tag marks everything a command produces as belonging to this tab. It is
// wrapped around the commands that answer from somewhere else — a turn, a
// local run, a compaction, a request to the endpoint — because by the time
// their answer arrives the tab on screen may be a different one, and the
// answer still belongs where it was asked.
//
// Only Codey's own messages are ever wrapped. Bubble Tea's — a batch, a quit,
// the mouse toggles — pass through untouched, because the runtime reads them
// itself and would never see one hidden inside an envelope.
func (m *model) tag(cmd tea.Cmd) tea.Cmd {
	if cmd == nil || m.id == 0 {
		return cmd
	}
	id := m.id
	return func() tea.Msg {
		msg := cmd()
		if msg == nil {
			return nil
		}
		return forTab{id: id, msg: msg}
	}
}

// busy says whether anything is in flight here: a turn, a fold, a local
// command, a worktree being made, a model list being fetched. The tab strip
// asks so it can mark the tab, and so it can restart the spinner of a tab that
// is put back on screen with work still running in it.
func (m *model) busy() bool {
	return m.phase == phaseRunning || m.compacting || m.worktreeing ||
		m.running != nil || m.modelsLoading
}

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 {
	// The wizard takes the screen, and may come back with a different
	// palette; nothing selected underneath it survives that.
	m.clearSelection()
	m.wiz = newWizard(m.opts.Client, m.opts.Config, m.opts.ClaudeSkills, m.opts.HerdrPresent && m.opts.InstallHerdrSkill != nil)
	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. It is handed over directly
		// rather than as a message to ourselves: on a first run the wizard
		// opens from Init, and a message that has to travel back through the
		// tab strip to get here is one more way for it to arrive at nobody.
		// When there is no size yet, the resize that brings one delivers it.
		m.wiz.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
	}
	return m.wiz.Init()
}

// 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)
	// Web search is the one answer this session cannot adopt: the tool set and
	// the hosted tool definitions were both built before the first turn. Saying
	// nothing is the worst of the options — the wizard would look like it had
	// accepted an answer it quietly dropped, and the turns after it would go by
	// with the agent unable to search and nobody able to see why.
	if want := cfg.WebSearchMode(); want != m.webSearchInForce() {
		m.tr.addNotice(webSearchRestartNote(want))
	}
	if m.opts.Policy != nil && m.opts.Policy.YOLO {
		m.tr.addNotice("accept-all is on — tool calls run without asking.")
	}
	if cfg.ClaudeSkillsMode() == config.ClaudeSkillsCopy && m.opts.CopyClaudeSkills != nil {
		// Done here rather than as a command: it is a copy between two
		// directories on the same disk, over in the time a frame takes, and a
		// notice that lands in the transcript before the screen is handed back
		// is one that is read. Skills are listed into the cached prompt prefix
		// at startup, so the copies are personal skills from the next start.
		m.tr.addNotice(claudeSkillsCopyNote(m.opts.CopyClaudeSkills()))
	}

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

	cmds := []tea.Cmd{m.sync(), textarea.Blink}
	if w.installSkill && m.opts.InstallHerdrSkill != nil {
		// Off the UI goroutine: this reaches the network, and a wizard that
		// closes onto a frozen screen is worse than one that says it is still
		// working. The answer arrives as a notice whenever it arrives.
		m.tr.addNotice("installing herdr's skill…")
		cmds = append(cmds, m.tag(installHerdrSkill(m.opts.InstallHerdrSkill)))
	}
	return tea.Batch(cmds...)
}

// herdrSkillMsg carries the outcome of the install back into the UI.
type herdrSkillMsg struct {
	path string
	err  error
}

// installHerdrSkill runs the install as an ordinary command, reporting failure
// as a value rather than by leaving a notice that says it is still going.
func installHerdrSkill(install func(context.Context) (string, error)) tea.Cmd {
	return func() tea.Msg {
		path, err := install(context.Background())
		return herdrSkillMsg{path: path, err: err}
	}
}

// onHerdrSkill says what came of it.
func (m *model) onHerdrSkill(msg herdrSkillMsg) tea.Cmd {
	if msg.err != nil {
		m.tr.addError("could not install herdr's skill: " + msg.err.Error() +
			"\nnothing was written; /setup will offer it again")
		return m.sync()
	}
	// Skills are discovered once, at startup, and the list of them is inside
	// the cached prompt prefix — so this session cannot pick one up without
	// paying a cache miss on every request after it. Saying so is the same
	// courtesy the web search answer gets.
	m.tr.addNotice("herdr's skill is installed at " + msg.path +
		"\nit is available from the next start of Codey; this session's skills were listed before it arrived")
	return m.sync()
}

// saveSession writes the conversation out. Every caller goes through here
// because the pair of directories is easy to get the wrong way round: the
// session is filed under the home root, never the worktree the work may have
// moved into, and the worktree rides along as a field so reopening the session
// can go back there.
func (m *model) saveSession() error {
	return m.opts.Agent.Save(m.opts.SessionDir, m.opts.SessionID,
		m.opts.HomeRoot, m.opts.Worktree, m.opts.Client.Model())
}

// runContinue drives a turn on the conversation as it stands — the report a
// subagent just filed is already in it. Everything else about the turn is the
// same, including saving the session when it ends.
func (m *model) runContinue() tea.Cmd {
	ctx, cancel := context.WithCancel(m.parent)
	m.cancel = cancel

	a, save := m.opts.Agent, m.saveSession
	return m.tag(func() tea.Msg {
		_, err := a.Continue(ctx)
		cancel()

		if saveErr := save(); saveErr != nil && err == nil {
			err = fmt.Errorf("saving session: %w", saveErr)
		}
		u, turns := a.State().Usage()
		return turnDone{err: err, usage: u, turns: turns}
	})
}

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

	// The markers in the text become the pictures they stand for, here at the
	// last moment: up to now they were characters someone could still delete,
	// and deleting one is how a paste is taken back.
	content := m.imgs.Blocks(prompt)
	m.imgs.Reset()

	a, save := m.opts.Agent, m.saveSession
	return m.tag(func() tea.Msg {
		_, err := a.RunBlocks(ctx, content)
		cancel()

		if saveErr := save(); 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 {
		if size, ok := msg.(tea.WindowSizeMsg); ok {
			// resize lays the chat out underneath and hands the size to the
			// wizard itself, so the message is not passed on a second time.
			return m, m.resize(size.Width, size.Height)
		}
		cmds := []tea.Cmd{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 tea.MouseMsg:
		return m.onMouse(msg)

	case copiedMsg:
		return m, m.finishCopy(msg)

	case imageMsg:
		return m, m.onImage(msg)

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

	case herdrSkillMsg:
		return m, m.onHerdrSkill(msg)

	case spinner.TickMsg:
		if m.phase == phaseRunning || m.modelsLoading || m.compacting || m.worktreeing || 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 evNotice:
		m.tr.addNotice(msg.text)
		return m, m.sync()

	case compactDone:
		return m, m.finishCompact(msg)

	case approvalMsg:
		if !m.inModal() {
			m.beforeModal = m.phase
		}
		m.pending = append(m.pending, msg)
		// An approval outranks a question already on screen: the question can
		// wait, and a tool call cannot start until someone says so.
		m.phase = phaseApproving
		return m, m.sync()

	case askMsg:
		if !m.inModal() {
			m.beforeModal = m.phase
		}
		m.asking = append(m.asking, newAskState(msg.req, msg.reply))
		// An approval already on screen keeps it, the way it does when a
		// second approval arrives: the question is behind it, not lost.
		if m.phase != phaseApproving {
			m.phase = phaseAsking
		}
		return m, m.sync()

	case evSub:
		return m, m.onSubEvent(msg.ev)

	case evAgents:
		return m, m.onAgentsChanged()

	case agentActed:
		return m, m.onAgentActed(msg)

	case turnDone:
		return m, m.finishTurn(msg)

	case localDone:
		return m, m.finishLocal(msg)

	case worktreeReady:
		return m, m.finishWorktree(msg)
	}

	return m, nil
}

func (m *model) resize(w, h int) tea.Cmd {
	// Every line is about to be laid out again, so the line numbers a
	// selection is made of stop pointing at the text they were taken from.
	m.clearSelection()
	m.width, m.height = w, h
	if !m.ready {
		m.vp = viewport.New(m.mainWidth(), maxInt(1, h-8))
		m.ready = true
	}
	// The wizard is sized here rather than only where messages are handled,
	// because not every resize arrives as a message: the tab strip lays each
	// conversation out by calling this directly. A wizard that never heard a
	// size renders nothing at all, which is what a first run looked like —
	// an empty screen until ctrl+c cancelled a wizard nobody could see.
	if m.wiz != nil {
		m.wiz.Update(tea.WindowSizeMsg{Width: w, Height: h})
	}
	m.relayout()
	m.vp.GotoBottom()
	return nil
}

// relayout re-flows everything that is sized against the main column, which
// is narrower whenever the sidebar is open.
func (m *model) relayout() tea.Cmd {
	if !m.ready {
		return nil
	}
	w := m.mainWidth()
	m.tr.setWidth(w)
	for _, t := range m.subTr {
		t.setWidth(w)
	}
	m.input.SetWidth(m.innerWidth())
	m.vp.Width = w
	m.refreshSplash()
	m.refreshPlaceholder()
	return m.sync()
}

// refreshPlaceholder fits the prompt's hint to the room there is for it. A
// placeholder is the one piece of text in the layout that may not be
// truncated: it is advertising keys, and "! shell · ↑ earli" advertises
// nothing while looking like a bug. The local modes have their own, and are
// left alone.
func (m *model) refreshPlaceholder() {
	if m.local == localOff {
		m.input.Placeholder = placeholderFor(m.innerWidth())
	}
}

// mainWidth is what is left for the transcript and the prompt.
func (m *model) mainWidth() int {
	if m.bar.visible() && !m.fullScreenSidebar() {
		return maxInt(20, m.width-sidebarWidth)
	}
	return m.width
}

// sidebarTakesOver reports whether the terminal is too narrow to show both.
// Squeezing a transcript into thirty columns shows neither.
func (m *model) fullScreenSidebar() bool {
	return m.bar.visible() && m.width-sidebarWidth < sidebarMinMain
}

func (m *model) sidebarWidth() int {
	if m.fullScreenSidebar() {
		return m.width
	}
	return sidebarWidth
}

// activeTranscript is what the main pane is showing: your own conversation, or
// the thread of the agent you opened.
func (m *model) activeTranscript() *transcript {
	if m.viewing != "" {
		if t, ok := m.subTr[m.viewing]; ok {
			return t
		}
	}
	return m.tr
}

// 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.mainWidth()
	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.activeTranscript().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()

	// An approval this turn raised belongs to a turn that no longer exists, so
	// it is denied. A subagent's does not: that agent is still running, and
	// the decision is still the user's to make.
	var kept []approvalMsg
	for _, p := range m.pending {
		if p.req.Agent == "" {
			p.reply <- approval.Deny
			continue
		}
		kept = append(kept, p)
	}
	m.pending = kept

	// A question outlives its turn no better than an approval does: the tool
	// call that asked it is gone, and nobody is left to read the answer. The
	// half-typed reply goes with it, and a subagent's question stays, for the
	// same reasons.
	var asking []*askState
	for _, s := range m.asking {
		if s.req.Agent == "" {
			s.reply <- ask.Response{Dismissed: true}
			continue
		}
		asking = append(asking, s)
	}
	m.asking = asking

	if len(m.pending) > 0 || len(m.asking) > 0 {
		m.beforeModal = phaseIdle
		m.phase = m.afterModal()
	}

	var full *agent.ContextFullError
	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")
	case errors.As(msg.err, &full):
		// The agent package has no idea what a slash command is, so the ways
		// out are named here rather than in the error it raised.
		m.tr.addError(full.Error() + " — /compact folds it into a summary, /clear starts over")
	default:
		m.tr.addError(msg.err.Error())
	}

	// How long that took, under the answer it belongs to. The activity line
	// counted the seconds while the turn ran and is gone the moment it ends,
	// so without this the one number you wanted to keep is the one the screen
	// throws away.
	if took := m.turnTook(); took != "" {
		m.tr.addNotice(took)
	}
	m.started = time.Time{}

	if !m.bar.hasKeys() {
		m.input.Focus()
	}
	// Agents that finished mid-turn have been waiting for this.
	return tea.Batch(m.sync(), textarea.Blink, m.deliverReports())
}

// turnTook reports how long the answer took, and over how many turns of the
// tool loop when it took more than one. It is empty when nothing timed the
// turn — a turnDone with no run behind it — because a duration measured from
// the zero time is worse than no duration at all.
func (m *model) turnTook() string {
	if m.started.IsZero() {
		return ""
	}
	s := "took " + elapsed(time.Since(m.started))
	if m.turn > 1 {
		s += fmt.Sprintf(" · %d turns", m.turn)
	}
	return s
}

func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	if m.phase == phaseApproving {
		return m.onApprovalKey(msg)
	}
	if m.phase == phaseAsking {
		return m.onAskKey(msg)
	}
	if m.phase == phaseEffort {
		return m.onEffortKey(msg)
	}
	if m.phase == phaseModel {
		return m.onModelKey(msg)
	}
	if m.phase == phaseSettings {
		return m.onSettingsKey(msg)
	}
	if m.phase == phaseSessions {
		return m.onSessionKey(msg)
	}
	if m.phase == phaseWorktrees {
		return m.onWorktreeKey(msg)
	}

	// The sidebar owns every key while it is open, the way the approval box
	// does: a pane with a kill key cannot share the keyboard with a prompt.
	if cmd, took := m.onSidebarKey(msg); took {
		return m, cmd
	}
	if msg.String() == "ctrl+b" && m.opts.Agents != nil {
		return m, m.toggleSidebar()
	}

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

	// Then the prompt's own memory. It comes after the menu and the local
	// modes because both of those have a prior claim on the arrow keys, and
	// before everything else because up and down otherwise reach a textarea
	// that will happily do nothing with them on a single-line prompt.
	if cmd, took := m.onHistoryKey(msg); took {
		return m, cmd
	}

	// A paste is inspected before the prompt box sees it, because the one
	// carrying an image carries it as a path, or as nothing at all. Only the
	// prompt aimed at the model does this: a picture is not something to hand
	// to a shell.
	if m.local == localOff {
		if cmd, took := m.onPasteKey(msg); took {
			return m, cmd
		} else if cmd != nil {
			// Something was said about the paste but it did not become a
			// picture, so the text still belongs in the box.
			m.input, _ = m.input.Update(msg)
			m.growInput()
			m.refreshMenu()
			return m, cmd
		}
		if msg.String() == "ctrl+v" {
			// The textarea binds this key to a paste of its own, but its
			// answer comes back as a message this model does not forward, so
			// the key did nothing before it was taken over here. It now means
			// the picture on the clipboard, or failing that the words on it.
			return m, m.grabImage(true, true)
		}
	}

	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 || m.compacting || m.worktreeing {
			return m, m.interrupt()
		}
		m.quitting = true
		return m, tea.Quit

	case "esc":
		m.clearSelection()
		if m.phase == phaseRunning || m.compacting || m.worktreeing {
			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":
		// Collapsing or expanding tool output lays every line out again, so
		// the selection is about text that has moved.
		m.clearSelection()
		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.compacting {
			// The compaction is about to replace the history a new prompt
			// would be appended to. Holding the text in the box keeps it.
			return m, nil
		}
		if m.worktreeing {
			// The work is about to move directories, and a prompt sent now
			// would be answered with tools pointing at the old one. Holding
			// the text in the box keeps it until the move lands.
			return m, nil
		}
		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
		}
		// Remembered before it is sent, and before anything can go wrong with
		// sending it: a prompt the agent refused is exactly the one you want
		// back in the box.
		m.hist.record(line)
		m.input.Reset()
		m.growInput()
		m.closeMenu()
		return m.submit(line)
	}

	// Typing moves on from whatever was selected, the way clicking elsewhere
	// does. Scrolling does not: reading further up the transcript with the
	// keys is no reason to lose what is marked.
	m.clearSelection()

	var cmd tea.Cmd
	before := m.input.Value()
	m.input, cmd = m.input.Update(msg)
	// Editing a recalled line ends the walk: it is your text now, and the
	// draft it started from is not coming back. Moving the cursor through it
	// is not editing, which is why this asks whether the text changed rather
	// than whether a key arrived.
	if m.input.Value() != before {
		m.hist.stop()
	}
	m.growInput()
	m.refreshMenu()
	// Emptying the prompt is what a report held back for it was waiting on.
	return m, tea.Batch(cmd, m.deliverReports())
}

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

// quit ends the program. It is a method because leaving is asked for from
// several places — /exit, ctrl+d, closing the last tab — and every one of them
// has to put the screen back the same way.
func (m *model) quit() tea.Cmd {
	m.quitting = true
	return tea.Quit
}

// 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":
		return m, m.quit()

	case line == "/new":
		// The strip owns the tabs; a model has no idea there are any. Saying
		// what is wanted and letting it be done there is what keeps a tab out
		// of the conversation's business.
		return m, func() tea.Msg { return newTabMsg{} }

	case line == "/close":
		return m, func() tea.Msg { return closeTabMsg{} }

	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 == "/compact":
		if m.phase == phaseRunning {
			m.tr.addError("/compact would rewrite the history the turn is building on — interrupt first")
			return m, m.sync()
		}
		if m.compacting {
			return m, nil
		}
		return m, m.startCompact()

	case line == "/worktree" || strings.HasPrefix(line, "/worktree "):
		return m, m.startWorktree(strings.TrimPrefix(line, "/worktree"))

	case line == "/clear":
		m.clearSelection()
		m.tr.clear()
		return m, m.sync()

	case line == "/resume" || strings.HasPrefix(line, "/resume "):
		if id := strings.TrimSpace(strings.TrimPrefix(line, "/resume")); id != "" {
			m.resumeSession(id)
			return m, m.sync()
		}
		return m, m.openSessionPicker()

	case line == "/image" || strings.HasPrefix(line, "/image "):
		return m, m.attachPath(strings.TrimPrefix(line, "/image"))

	case line == "/mouse":
		return m, m.toggleMouse()

	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  "))
		}
		// The standing rules, by the file each came from, so a call that
		// never prompted can be traced to the line that let it through.
		if rules := m.opts.Policy.RuleLines(); len(rules) > 0 {
			m.tr.addNotice("from Claude Code's settings:\n  " + strings.Join(rules, "\n  "))
		}
		return m, m.sync()

	case line == "/settings":
		return m, m.openSettings()

	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 {
			m.tr.addNotice(m.skillsText())
		}
		return m, m.sync()

	case strings.HasPrefix(line, "/") && !m.isSkill(line):
		m.tr.addError("unknown command " + line + " — /help lists them")
		return m, m.sync()
	}

	return m, m.startTurn(line, line)
}

// startTurn puts a prompt to the agent and moves the UI into a running turn.
// shown is what the transcript displays and prompt is what the model is sent:
// they are the same for anything typed, and differ for /worktree, which has
// to tell the model where it now is without that preamble appearing as though
// you had written it.
func (m *model) startTurn(shown, prompt string) tea.Cmd {
	// Anything a subagent reported while you were typing goes up as its own
	// message, ahead of yours: the model should not have to work out which
	// half of one message was whose.
	m.flushReportsAhead()

	// A path to a picture becomes the picture, however it got into the box.
	// The paste key cannot catch it on every terminal — bracketed paste is
	// not universal — so the last word on it is here, where the prompt is
	// already whole. It happens once: expanding what is shown and what is
	// sent separately would attach the same file twice.
	if expanded := m.imgs.ExpandPaths(prompt); expanded != prompt {
		if shown == prompt {
			shown = expanded
		}
		prompt = expanded
	}

	if shown != "" {
		m.tr.addUser(shown)
	}
	// The first thing asked is what the tab is called from here on. It is what
	// someone reading a row of tabs is looking for, and it does not change
	// afterwards: a tab that renamed itself every turn would be a tab you had
	// to re-find every turn.
	if m.title == "" {
		m.title = firstLine(shown)
	}
	m.phase = phaseRunning
	m.turn = 0
	m.started = time.Now()
	m.verbSeed++
	return tea.Batch(m.sync(), m.spin.Tick, m.runTurn(prompt))
}

// 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 Codey 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 = m.afterModal()
		return m, nil
	}
	req := m.pending[0]
	key := msg.String()

	// ctrl-c mid-prompt denies the call and cancels the turn it belonged to.
	// With nothing of yours running there is no turn to cancel — the denial
	// is the whole of it.
	if key == "ctrl+c" {
		req.reply <- approval.Deny
		m.pending = m.pending[1:]
		m.phase = m.afterModal()
		if m.phase == phaseRunning {
			return m, tea.Batch(m.interrupt(), m.sync())
		}
		return m, 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
		}
		return m, m.answerApproval(c)
	}
	return m, nil
}

// afterModal is where the session goes once a box is answered: on to the next
// thing waiting — another approval, or a question that had to stand aside for
// one — and otherwise back to whatever it was doing. Neither kind of box
// implies a turn, since a subagent can raise one while you are idle, so
// answering puts the session back where it found it.
func (m *model) afterModal() phase {
	switch {
	case len(m.pending) > 0:
		return phaseApproving
	case len(m.asking) > 0:
		return phaseAsking
	}
	return m.beforeModal
}

// inModal says a box is on screen, which is how the session knows whether
// m.beforeModal already holds something worth going back to.
func (m *model) inModal() bool { return m.phase == phaseApproving || m.phase == phaseAsking }

// answerApproval sends a decision and moves on. It is shared by the keys and
// by a click on the choice, which must not be able to decide differently.
func (m *model) answerApproval(c choice) tea.Cmd {
	if len(m.pending) == 0 {
		return nil
	}
	req := m.pending[0]
	req.reply <- c.dec
	m.pending = m.pending[1:]
	m.phase = m.afterModal()
	// 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 tea.Batch(m.sync(), m.spin.Tick)
}

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

	// Too narrow for both: the pane takes the screen rather than leaving two
	// unreadable columns. The status line goes with the transcript, so the
	// pill it carries is not on screen and its click target is forgotten
	// rather than left pointing at whatever is drawn there now.
	if m.fullScreenSidebar() {
		m.pill = rowHit{}
		return m.sidebarView(m.height)
	}

	// 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()
	top := ""
	if m.viewing != "" {
		top = m.agentHeader(m.mainWidth()) + "\n"
	}
	if h := maxInt(1, m.height-lipgloss.Height(bottom)-strings.Count(top, "\n")); h != m.vp.Height {
		atBottom := m.vp.AtBottom()
		m.vp.Height = h
		if atBottom {
			m.vp.GotoBottom()
		}
	}
	// The guard is not only tidiness: asking the transcript for its lines
	// splits the whole scrollback, and a streaming turn redraws every frame.
	// Nothing pays for that until there is a selection to paint. It is the
	// transcript on screen that is painted, which is a subagent's thread when
	// one is open.
	view := m.vp.View()
	if m.sel.on {
		if from, to, ok := m.sel.span(m.activeTranscript().lines()); ok {
			view = paintSelection(view, m.vp.YOffset, from, to)
		}
	}
	main := top + view + "\n" + bottom
	if !m.bar.visible() {
		return main
	}
	// The pane is a column beside the whole screen, chrome included: the
	// transcript reflows into what is left rather than being covered by it.
	return lipgloss.JoinHorizontal(lipgloss.Top, main, m.sidebarView(m.height))
}

// bottom is the chrome under the transcript. It is assembled from layout()
// rather than written out here, because a mouse click is resolved against
// that same list and the two must be the same screen.
func (m *model) bottom() string {
	parts := make([]string, 0, 5)
	for _, s := range m.layout() {
		parts = append(parts, s.text)
	}
	return strings.Join(parts, "\n")
}

// 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.mainWidth())
	}

	if m.compacting {
		return spread(m.compactActivity(), "", m.width)
	}

	if m.worktreeing {
		return spread(m.worktreeActivity(), "", 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")
	case phaseAsking:
		left = styWarn.Render("● the agent has a question")
	}

	// What was copied is reported here rather than in the transcript: five
	// copies should not cost five lines of scrollback, and this line is on
	// screen in every phase.
	right := ""
	if m.copied != "" {
		right = styDim.Render(m.copied)
	}
	if m.opts.Effort != "" {
		if right != "" {
			right += styFaint.Render(" · ")
		}
		// The pill is a claim about the next request. On a model that does
		// not take this level the request goes out without it rather than
		// earning a 400, so the pill says which model is ignoring it instead
		// of reporting a setting that is not in force.
		note := " · /effort"
		if !llm.SupportsEffort(m.opts.Client.Model(), m.opts.Effort) {
			note = " · not on " + shortModel(m.opts.Client.Model())
		}
		right += styDim.Render("● "+m.opts.Effort) + styFaint.Render(note)
	}
	return spread(left, right, m.mainWidth())
}

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.mainWidth()) }

func (m *model) inputBox() string {
	style := styBoxFocus
	switch {
	case m.phase == phaseRunning, m.bar.hasKeys():
		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)
	}

	if pill, ok := m.agentsPill(); 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 Codey 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)))
		}
	}
	line := strings.Join(parts, "  ")
	m.recordPill(parts)
	return truncateStyled(line, maxInt(1, m.mainWidth()))
}

// agentsPill is the clickable button in the status line: how many agents are
// live, or just the way in when none are.
func (m *model) agentsPill() (string, bool) {
	if m.opts.Agents == nil {
		return "", false
	}
	label := "⏵ agents"
	style := styPill
	if n := m.bar.live; n > 0 {
		label = fmt.Sprintf("⏵ %d agents", n)
		style = styPill.Foreground(colCoral)
	} else if len(m.bar.rows) == 0 {
		return "", false
	}
	return style.Render(label), true
}

// recordPill remembers where in the status line the agents pill was drawn, so
// a click on it can be told from a click on the rest of that line. Only the
// columns are recorded: which row the line landed on is the layout's business,
// and a click arrives already resolved to it.
func (m *model) recordPill(parts []string) {
	m.pill = rowHit{}
	pill, ok := m.agentsPill()
	if !ok {
		return
	}
	var x int
	for _, p := range parts {
		if p == pill {
			break
		}
		x += lipgloss.Width(p) + 2
	}
	m.pill = rowHit{x0: x, x1: x + lipgloss.Width(pill) - 1}
}

// 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 {
	// While a box is up, shift+tab belongs to it and the line would be
	// advertising a key that does something else.
	if m.inModal() {
		return ""
	}
	agents := ""
	if m.opts.Agents != nil {
		agents = " · ctrl+b agents"
	}
	if m.opts.Policy != nil && m.opts.Policy.YOLO {
		return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"+agents), maxInt(1, m.mainWidth()))
	}
	return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle)"+agents+" · /help"), maxInt(1, m.mainWidth()))
}

func (m *model) approvalView() string {
	req := m.pending[0].req
	width := maxInt(20, m.mainWidth()-2)
	inner := width - 4

	var b strings.Builder
	b.WriteString(m.approvalHead(req) + 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"))
}

// approvalHead is the first thing the box says: the tool, and — when it is not
// the session's own agent asking — which subagent wants it. Four agents can be
// running, and "bash wants to run rm -rf build" is not a question anybody can
// answer without knowing whose it is.
func (m *model) approvalHead(req approval.Request) string {
	if req.Agent == "" {
		return styHeading.Render(req.Tool)
	}
	who := req.Agent
	if info, ok := m.agentInfo(req.Agent); ok {
		who += " (" + info.Preset + ")"
	}
	return styCoral.Render(who) + styDim.Render(" · ") + styHeading.Render(req.Tool)
}

// 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.
// addCostAt bills a subagent's usage to the session at that agent's own model:
// a haiku agent spawned from an opus session was not billed at opus rates.
//
// Only the money is pooled. The token counts in the status line stay the main
// conversation's, because the gauge beside them is about the window this
// conversation is filling — a child's tokens went into a different window, and
// the sidebar reports them per agent.
func (m *model) addCostAt(u llm.Usage, model string) {
	if u == (llm.Usage{}) {
		return
	}
	if model == "" {
		model = m.opts.Client.Model()
	}
	if c, known := u.Cost(model); known {
		m.cost += c
		m.costKnown = true
	}
}

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
}