Download
1039 lines 29.7 KiBGoModified 2026-10-31 raw
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039
// Package tui is blinker's terminal interface: the conversation, approval
// prompts, and setup. It runs inline rather than full screen: finished
// output goes into the terminal's scrollback, where it can be scrolled,
// searched and copied, and only the live part at the bottom is redrawn.
package tui

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"

	"charm.land/bubbles/v2/key"
	"charm.land/bubbles/v2/spinner"
	"charm.land/bubbles/v2/textarea"
	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	"github.com/charmbracelet/x/ansi"

	"blinker/internal/agent"
	"blinker/internal/config"
	"blinker/internal/diffview"
	"blinker/internal/history"
	"blinker/internal/llm"
	"blinker/internal/policy"
	"blinker/internal/sandbox"
	"blinker/internal/tooldef"
	"blinker/internal/tools"
)

var (
	accent     = lipgloss.Color("12")
	plainStyle = lipgloss.NewStyle()
	userStyle  = lipgloss.NewStyle().Bold(true).Foreground(accent)
	dimStyle   = lipgloss.NewStyle().Foreground(dimOnDark)
	thinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Italic(true)
	okStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
	errStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
	warnStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true)
	selStyle   = lipgloss.NewStyle().Bold(true).Foreground(accent)
	boxStyle   = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("11")).Padding(0, 1)
)

// Secondary text is light gray on a dark background and darker gray on a
// light one (see the BackgroundColorMsg case in Update). ANSI 8, the usual
// "dim", is barely readable on many dark themes.
var dimOnDark, dimOnLight = lipgloss.Color("250"), lipgloss.Color("242")

// clockwiseDot is spinner.Dot with its frames reversed: in Dot the gap
// runs down the left side and up the right, which turns counterclockwise.
var clockwiseDot = spinner.Spinner{
	Frames: []string{"⣷ ", "⣯ ", "⣟ ", "⡿ ", "⢿ ", "⣻ ", "⣽ ", "⣾ "},
	FPS:    spinner.Dot.FPS,
}

// Deps are what the interface works with.
type Deps struct {
	Config    config.Config
	ConfigDir string
	StateDir  string
	SB        *sandbox.Sandbox
	// NewClient builds the LLM client for a config.
	NewClient func(config.Config) (*llm.Client, error)
	// SetupOnly runs setup and quits.
	SetupOnly bool
}

// bridge lets goroutines (the agent, the proxy) reach the program.
type bridge struct {
	mu sync.Mutex
	p  *tea.Program
}

func (b *bridge) send(msg tea.Msg) {
	b.mu.Lock()
	p := b.p
	b.mu.Unlock()
	if p != nil {
		p.Send(msg)
	}
}

// Messages from other goroutines.
type (
	eventMsg    struct{ ev agent.Event }
	questionMsg struct {
		ctx   context.Context
		q     tools.Question
		reply chan tools.Answer
	}
	printedMsg   struct{}
	editorDone   struct{ err error }
	setupDoneMsg struct{ cfg config.Config }
)

// question is a question waiting for the user.
type question struct {
	ctx    context.Context
	q      tools.Question
	reply  chan tools.Answer
	shown  time.Time // see questionGrace
	sel    int
	noting bool // typing a note for the model
	note   textinput.Model
	// With Question.Pager: its body and diff, rendered, and the first
	// line shown.
	pager []string
	top   int
}

// Model is the interface's state.
type Model struct {
	d Deps
	// saved is the settings as last saved, which /config and /setup start
	// from. d.Config is those in force, until the next start.
	saved  config.Config
	bridge *bridge
	width  int
	height int

	client *llm.Client
	agent  *agent.Agent
	tools  *tools.Toolbox

	input   textarea.Model
	history []string // earlier inputs, oldest first
	histPos int      // len(history) when not browsing
	draft   string   // the input being typed before browsing history

	running bool
	cancel  context.CancelFunc
	started time.Time
	// The reply streaming in. Pointers, since Bubble Tea copies the model
	// on every update and a used strings.Builder must not be copied.
	reply     *strings.Builder
	reasoning *strings.Builder
	toolName  string // the tool running now
	toolArgs  string
	queued    []string
	showThink bool
	question  *question
	waiting   []questionMsg // questions asked while another was up, oldest first
	spin      spinner.Model

	// Output waiting to go into the scrollback, one print at a time so it
	// keeps its order.
	out      []string
	printing bool

	setup    *setupModel
	settings *settingsModel
	quitting bool
}

// Run runs the interface until the user quits.
func Run(d Deps) error {
	b := &bridge{}
	m := newModel(d, b)
	p := tea.NewProgram(m)
	b.mu.Lock()
	b.p = p
	b.mu.Unlock()
	d.SB.SetAskDomain(func(ctx context.Context, host string) sandbox.DomainAnswer {
		opts := []tools.Option{
			{Label: "Allow for this script", Allow: true},
			{Label: "Allow in Unsafe_Bash for the session", Allow: true},
			{Label: "Always allow in Unsafe_Bash (adds a host rule to this project's unsafe policy)", Allow: true},
			{Label: "Deny"},
		}
		answers := []sandbox.DomainAnswer{sandbox.DomainAllowOnce, sandbox.DomainAllowSession, sandbox.DomainAllowAlways}
		a := ask(ctx, b, tools.Question{
			Tool:  "network",
			Title: fmt.Sprintf("The running Unsafe_Bash script wants to connect to %q", host),
			Opts:  opts,
		})
		for i, ans := range answers {
			if a.Option == opts[i] {
				return ans
			}
		}
		return sandbox.DomainDeny
	})
	_, err := p.Run()
	return err
}

// ask puts a question to the user and waits. If ctx ends first, the answer
// denies.
func ask(ctx context.Context, b *bridge, q tools.Question) tools.Answer {
	reply := make(chan tools.Answer, 1)
	b.send(questionMsg{ctx: ctx, q: q, reply: reply})
	select {
	case a := <-reply:
		return a
	case <-ctx.Done():
		return tools.Answer{}
	}
}

func newModel(d Deps, b *bridge) Model {
	in := newInput()
	in.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j", "alt+enter", "shift+enter"))

	m := Model{
		d:         d,
		saved:     d.Config,
		bridge:    b,
		input:     in,
		spin:      spinner.New(spinner.WithSpinner(clockwiseDot)),
		reply:     &strings.Builder{},
		reasoning: &strings.Builder{},
		width:     80,
		height:    24,
	}
	if prompts, err := history.Prompts(d.StateDir, d.SB.Project(), 500); err == nil {
		m.history = prompts
	}
	m.histPos = len(m.history)
	m.tools = tools.New(d.SB, func(ctx context.Context, q tools.Question) tools.Answer { return ask(ctx, b, q) },
		time.Duration(d.Config.CommandTimeout))
	// Init runs the command of the setup this opens.
	if d.SetupOnly || !d.Config.Configured() {
		m.openSetup(d.Config, nil)
	} else if err := m.connect(d.Config); err != nil {
		m.openSetup(d.Config, err)
	}
	return m
}

// openSetup opens setup with cfg, saying why if err is set, and returns
// setup's first command.
func (m *Model) openSetup(cfg config.Config, err error) tea.Cmd {
	s := newSetup(cfg, m.d.ConfigDir, m.d.NewClient)
	s.width = m.width
	if err != nil {
		s.errText = err.Error()
	}
	m.setup = &s
	return s.Init()
}

// connect builds the client and agent for a config.
func (m *Model) connect(cfg config.Config) error {
	client, err := m.d.NewClient(cfg)
	if err != nil {
		return err
	}
	m.d.Config, m.saved = cfg, cfg
	m.client = client
	m.tools.Model = cfg.Model
	m.agent = agent.New(client, m.tools, m.d.ConfigDir, m.d.StateDir, func(ev agent.Event) { m.bridge.send(eventMsg{ev}) })
	return nil
}

func (m Model) Init() tea.Cmd {
	if m.setup != nil {
		return tea.Batch(tea.RequestBackgroundColor, m.setup.Init())
	}
	return tea.Batch(tea.RequestBackgroundColor, textarea.Blink, m.greeting())
}

func (m *Model) greeting() tea.Cmd {
	pol, loaded := m.d.SB.Policy()
	count := func(f *policy.File) int { return len(f.Files) + len(f.Network) + len(f.Tools) + len(f.Env) }
	where := fmt.Sprintf("%d safe and %d unsafe project rules", count(loaded.Own[policy.SafeMode].File), count(loaded.Own[policy.UnsafeMode].File))
	net := "none"
	if d := pol.Safe.AllowedHosts(); len(d) > 0 {
		net = strings.Join(d, ", ")
	}
	m.print(fmt.Sprintf("%s %s · %s\n%s",
		userStyle.Render("blinker"), dimStyle.Render(Sanitize(m.d.Config.Model)), dimStyle.Render(Sanitize(m.d.SB.Project())),
		dimStyle.Render(fmt.Sprintf("sandbox: %s · policy: %s (/policy) · safe network: %s", m.d.SB.Backend(), where, net))))
	m.printLint()
	return m.flush()
}

// print queues text for the scrollback.
func (m *Model) print(s string) { m.out = append(m.out, s) }

// flush starts printing queued output, if not already printing.
func (m *Model) flush() tea.Cmd {
	if m.printing || len(m.out) == 0 {
		return nil
	}
	text := strings.Join(m.out, "\n")
	m.out = nil
	m.printing = true
	return tea.Sequence(tea.Println(text), func() tea.Msg { return printedMsg{} })
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmds []tea.Cmd
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
		m.input.SetWidth(max(msg.Width-2, 10))
		if m.setup != nil {
			m.setup.width = msg.Width
		}
		if m.settings != nil {
			m.settings.width = msg.Width
		}
		if q := m.question; q != nil && q.q.Pager {
			q.pager = m.pagerLines(q.q)
			q.top = min(q.top, m.maxTop(q))
		}
	case tea.BackgroundColorMsg:
		if !msg.IsDark() {
			dimStyle = dimStyle.Foreground(dimOnLight)
			markSGR = "38;5;242"
			diffStyles = diffview.Light
		}
	case printedMsg:
		m.printing = false
		return m, m.flush()
	case setupCancel:
		m.setup = nil
		if m.agent == nil || m.d.SetupOnly {
			m.quitting = true
			return m, tea.Quit
		}
		m.print(dimStyle.Render("Setup canceled; the settings are unchanged."))
		return m, m.flush()
	case setupDoneMsg:
		m.setup = nil
		if m.d.SetupOnly {
			m.print(okStyle.Render("Saved the settings in " + m.d.ConfigDir))
			m.quitting = true
			return m, tea.Sequence(m.flush(), tea.Quit)
		}
		if err := m.connect(msg.cfg); err != nil {
			return m, m.openSetup(msg.cfg, err)
		}
		return m, tea.Batch(textarea.Blink, m.greeting())
	case settingsDone:
		m.settings = nil
		if msg.saved {
			m.saved = msg.cfg
			m.print(dimStyle.Render("Saved the settings in " + m.d.ConfigDir + ". They apply the next time blinker starts."))
		}
		return m, tea.Batch(textarea.Blink, m.flush())
	case questionMsg:
		if msg.ctx.Err() != nil {
			msg.reply <- tools.Answer{}
			return m, nil
		}
		if m.question != nil {
			// One question at a time; the newer one waits its turn.
			m.waiting = append(m.waiting, msg)
			return m, nil
		}
		m.showQuestion(msg)
		// Ring the bell: the user may be looking elsewhere.
		return m, tea.Batch(m.flush(), tea.Raw("\a"))
	case eventMsg:
		m.handleEvent(msg.ev)
		if _, done := msg.ev.(agent.TurnDone); done && len(m.queued) > 0 && !m.quitting {
			next := m.queued[0]
			m.queued = m.queued[1:]
			cmds = append(cmds, m.submit(next))
		}
	case diffEdited:
		m.diffEditDone(msg)
		return m, tea.Batch(textarea.Blink, m.flush())
	case editorDone:
		if msg.err != nil {
			m.print(errStyle.Render("Editor: " + msg.err.Error()))
		}
		m.reloadPolicy()
	case spinner.TickMsg:
		if m.running || m.setup != nil {
			var cmd tea.Cmd
			m.spin, cmd = m.spin.Update(msg)
			cmds = append(cmds, cmd)
		}
	case tea.KeyPressMsg:
		if msg.String() == "ctrl+z" {
			return m, tea.Suspend
		}
		if m.setup == nil && m.settings == nil {
			return m.key(msg)
		}
	}
	_, isKey := msg.(tea.KeyPressMsg)
	switch {
	case m.settings != nil:
		s, cmd := m.settings.Update(msg)
		m.settings = &s
		cmds = append(cmds, cmd)
	case m.setup != nil:
		s, cmd := m.setup.Update(msg)
		m.setup = &s
		cmds = append(cmds, cmd)
	case !isKey:
		// Anything else, such as pasted text, goes to the field being
		// edited.
		var cmd tea.Cmd
		if q := m.question; q != nil && q.noting {
			q.note, cmd = q.note.Update(msg)
		} else {
			m.input, cmd = m.input.Update(msg)
		}
		cmds = append(cmds, cmd)
	}
	cmds = append(cmds, m.flush())
	return m, tea.Batch(cmds...)
}

func (m *Model) printQuestion(q tools.Question) {
	var b strings.Builder
	b.WriteString(warnStyle.Render("? " + diffview.Visible(q.Title)))
	if !q.Pager {
		if q.Body != "" {
			b.WriteString("\n" + indent(diffview.Visible(q.Body), "  │ "))
		}
		if q.Diff != "" {
			b.WriteString("\n" + m.renderDiff(diffview.Visible(q.Diff), 0))
		}
		if q.About != "" {
			b.WriteString("\n" + indent(diffview.Visible(q.About), "  "))
		}
	}
	m.print(b.String())
}

// pagerLines renders a question's body, diff and explanation for its
// pager, wrapped to the screen so that each is one line on it.
func (m *Model) pagerLines(q tools.Question) []string {
	var parts []string
	if q.Body != "" {
		parts = append(parts, indent(wrap(diffview.Visible(q.Body), m.width-2), "  "))
	}
	if q.Diff != "" {
		parts = append(parts, m.renderDiff(diffview.Visible(q.Diff), 0))
	}
	if q.About != "" {
		parts = append(parts, indent(wrap(diffview.Visible(q.About), m.width-2), "  "))
	}
	return strings.Split(strings.Join(parts, "\n\n"), "\n")
}

// pagerHeight is how many lines of a pager fit above the question's box.
func (m *Model) pagerHeight(q *question) int {
	// The box's border, title and hint, its options, the line under the
	// pager, and the empty last line. (The spinner's line is hidden while
	// a question waits.)
	return max(m.height-len(q.q.Opts)-6, 3)
}

func (m *Model) maxTop(q *question) int { return max(len(q.pager)-m.pagerHeight(q), 0) }

func (m *Model) handleEvent(ev agent.Event) {
	switch ev := ev.(type) {
	case agent.Token:
		if ev.Reasoning {
			m.reasoning.WriteString(ev.Text)
		} else {
			m.reply.WriteString(ev.Text)
		}
	case agent.ReplyDone:
		if r := strings.TrimSpace(m.reasoning.String()); r != "" && m.showThink {
			m.print(styleLines(thinkStyle, wrap(Sanitize(r), m.width-2)))
		}
		if r := strings.TrimSpace(m.reply.String()); r != "" {
			m.print(markdown(Sanitize(r), m.width) + "\n")
		}
		m.reply.Reset()
		m.reasoning.Reset()
	case agent.ToolStart:
		m.toolName, m.toolArgs = ev.Name, ev.Args
	case agent.ToolDone:
		m.toolName = ""
		m.printTool(ev.Result)
	case agent.Notice:
		m.print(styleLines(warnStyle, wrap(Sanitize(ev.Text), m.width)))
	case agent.TurnDone:
		m.running = false
		m.toolName = ""
		if m.cancel != nil {
			m.cancel()
			m.cancel = nil
		}
		m.dismissQuestion()
		if ev.Err != nil {
			if errors.Is(ev.Err, context.Canceled) {
				m.print(dimStyle.Render("Interrupted."))
			} else {
				m.print(errStyle.Render("Error: " + Sanitize(ev.Err.Error())))
			}
		}
	}
}

// closeQuestion takes down the question once answered. The turn's clock
// leaves out the time the question waited for the user.
func (m *Model) closeQuestion() {
	m.started = m.started.Add(time.Since(m.question.shown))
	m.question = nil
	// The next question still waiting for its answer takes its place.
	for len(m.waiting) > 0 {
		next := m.waiting[0]
		m.waiting = m.waiting[1:]
		if next.ctx.Err() != nil {
			next.reply <- tools.Answer{}
			continue
		}
		m.showQuestion(next)
		return
	}
}

// showQuestion puts a question up, to answer before anything else.
func (m *Model) showQuestion(msg questionMsg) {
	ti := textinput.New()
	ti.Placeholder = "why (sent to the model)"
	ti.SetWidth(min(m.width, 100) - 6)
	m.question = &question{ctx: msg.ctx, q: msg.q, reply: msg.reply, note: ti, shown: time.Now()}
	if msg.q.Pager {
		m.question.pager = m.pagerLines(msg.q)
	}
	m.printQuestion(msg.q)
}

// dismissQuestion answers a pending question with a denial.
func (m *Model) dismissQuestion() {
	if m.question == nil {
		return
	}
	m.question.reply <- tools.Answer{}
	m.closeQuestion()
}

// diffStyles are the colors of diffs: Dark until the terminal says its
// background is light.
var diffStyles = diffview.Dark

// renderDiff shows a diff, keeping at most limit lines (0 for all), with
// the diff_command if one is set, or else as diffview.Render does.
func (m *Model) renderDiff(d string, limit int) string {
	argv := m.d.Config.DiffCommand
	if len(argv) == 0 {
		return diffview.Render(d, limit, m.width, diffStyles)
	}
	out, err := externalDiff(argv, d, limit, m.width)
	if err != nil {
		return diffview.Render(d, limit, m.width, diffStyles) + "\n" +
			warnStyle.Render("diff_command failed: "+Sanitize(err.Error()))
	}
	return out
}

func (m *Model) printTool(r tools.Result) {
	mark := okStyle.Render("●")
	if r.Failed {
		mark = errStyle.Render("●")
	}
	var b strings.Builder
	b.WriteString(mark + " " + Sanitize(r.Summary))
	detail := strings.TrimRight(Sanitize(r.Detail), "\n")
	switch {
	case r.Failed && detail == "" && r.Output != "":
		b.WriteString("\n" + result(clipLines(wrap(Sanitize(r.Output), m.width-4), 8), plainStyle))
	case r.Diff && detail != "":
		b.WriteString("\n" + m.renderDiff(detail, 40))
	case detail != "":
		b.WriteString("\n" + result(clipLines(wrap(detail, m.width-4), 12), dimStyle))
	}
	m.print(b.String())
}

// key handles a key press in the conversation.
func (m Model) key(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	if m.question != nil {
		return m.questionKey(msg)
	}
	switch msg.String() {
	case "ctrl+c":
		switch {
		case m.running:
			m.interrupt()
		case m.input.Value() != "":
			m.input.Reset()
		default:
			return m, m.quit()
		}
		return m, m.flush()
	case "ctrl+d":
		if m.input.Value() == "" {
			return m, m.quit()
		}
	case "esc":
		if m.running {
			m.interrupt()
			return m, m.flush()
		}
	case "ctrl+t":
		m.showThink = !m.showThink
		state := "hidden"
		if m.showThink {
			state = "shown"
		}
		m.print(dimStyle.Render("Model reasoning will be " + state + "."))
		return m, m.flush()
	case "enter":
		text := strings.TrimSpace(m.input.Value())
		if text == "" {
			return m, nil
		}
		m.input.Reset()
		m.addHistory(text)
		if m.running && !strings.HasPrefix(text, "/") {
			m.queued = append(m.queued, text)
			m.print(dimStyle.Render("Queued: " + Sanitize(firstLine(text))))
			return m, m.flush()
		}
		cmd := m.submit(text)
		return m, tea.Batch(cmd, m.flush())
	// Up and down move the cursor between rows, wrapped ones too, and go
	// through history from the first row and the last.
	case "up":
		if m.input.Line() == 0 && m.input.LineInfo().RowOffset == 0 && m.histPos > 0 {
			if m.histPos == len(m.history) {
				m.draft = m.input.Value()
			}
			m.histPos--
			m.input.SetValue(m.history[m.histPos])
			return m, nil
		}
	case "down":
		info := m.input.LineInfo()
		if m.input.Line() == m.input.LineCount()-1 && info.RowOffset == info.Height-1 && m.histPos < len(m.history) {
			m.histPos++
			if m.histPos == len(m.history) {
				m.input.SetValue(m.draft)
			} else {
				m.input.SetValue(m.history[m.histPos])
			}
			return m, nil
		}
	}
	var cmd tea.Cmd
	m.input, cmd = m.input.Update(msg)
	return m, cmd
}

func (m *Model) addHistory(text string) {
	if len(m.history) == 0 || m.history[len(m.history)-1] != text {
		m.history = append(m.history, text)
	}
	m.histPos = len(m.history)
	m.draft = ""
}

func (m *Model) interrupt() {
	if m.cancel != nil {
		m.cancel()
	}
	m.queued = nil
	m.dismissQuestion()
}

func (m *Model) quit() tea.Cmd {
	m.interrupt()
	m.quitting = true
	return tea.Sequence(m.flush(), tea.Quit)
}

// questionGrace is how long after a question appears Enter and the digits
// cannot answer it (Esc still denies): a question can arrive while the
// user types something else, and their next Enter (on Allow, the first
// option) or digit was not meant for it.
const questionGrace = 500 * time.Millisecond

func (m Model) questionKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	q := m.question
	early := time.Since(q.shown) < questionGrace
	if q.noting {
		switch msg.String() {
		case "enter":
			q.reply <- tools.Answer{Option: q.q.Opts[q.sel], Note: strings.TrimSpace(q.note.Value())}
			m.print(dimStyle.Render("  → " + q.q.Opts[q.sel].Label + ": " + Sanitize(q.note.Value())))
			m.closeQuestion()
			return m, m.flush()
		case "esc":
			q.noting = false
			q.note.Blur()
			return m, nil
		}
		var cmd tea.Cmd
		q.note, cmd = q.note.Update(msg)
		return m, cmd
	}
	if q.q.Pager {
		page := m.pagerHeight(q) - 1
		scroll := map[string]int{"up": -1, "k": -1, "down": 1, "j": 1, "pgup": -page, "b": -page,
			"pgdown": page, "space": page, "f": page, "home": -len(q.pager), "g": -len(q.pager),
			"end": len(q.pager), "G": len(q.pager)}
		if n, ok := scroll[msg.String()]; ok {
			q.top = min(max(q.top+n, 0), m.maxTop(q))
			return m, nil
		}
	}
	switch msg.String() {
	case "up", "k", "shift+tab":
		q.sel = (q.sel + len(q.q.Opts) - 1) % len(q.q.Opts)
	case "down", "j", "tab":
		q.sel = (q.sel + 1) % len(q.q.Opts)
	case "esc":
		// Deny.
		for i, o := range q.q.Opts {
			if !o.Allow && !o.Explain {
				q.sel = i
			}
		}
		return m.answer()
	case "ctrl+c":
		if m.running {
			m.interrupt()
		}
		return m, m.flush()
	case "enter":
		if early {
			return m, nil
		}
		return m.answer()
	default:
		if s := msg.String(); !early && len(s) == 1 && s[0] >= '1' && s[0] <= '9' {
			if i := int(s[0] - '1'); i < len(q.q.Opts) {
				q.sel = i
				return m.answer()
			}
		}
	}
	return m, nil
}

func (m Model) answer() (tea.Model, tea.Cmd) {
	q := m.question
	opt := q.q.Opts[q.sel]
	if opt.Explain {
		q.noting = true
		return m, q.note.Focus()
	}
	if opt.Edit {
		return m, m.editDiff(q.q.Edits)
	}
	q.reply <- tools.Answer{Option: opt}
	style := okStyle
	if !opt.Allow {
		style = errStyle
	}
	m.print(style.Render("  → " + opt.Label))
	m.closeQuestion()
	return m, m.flush()
}

// submit handles a line of input: a command, or a message for the agent.
func (m *Model) submit(text string) tea.Cmd {
	if strings.HasPrefix(text, "/") {
		return m.command(text)
	}
	m.print(userStyle.Render("❯ ") + Sanitize(text))
	ctx := m.startTurn()
	go m.agent.Turn(ctx, text)
	return m.spin.Tick
}

const helpText = `Commands:
  /help            this help
  /clear           start a new conversation
  /compact         free context: remove old tool output, and have the model
                   summarize the conversation so far
  /policy          edit this project's access policy in $EDITOR
  /policy global   edit the global access policy in $EDITOR
  /policy REQUEST  have the model change a policy as you ask, for you to review
  /show PATH...    let the model read these paths without asking
  /hide PATH...    keep the model from these paths, in every mode
  /tool edit NAME  edit a tool (a new one starts from a template)
  /setup           choose the LLM backend again
  /config          edit the settings
  /quit            quit
Keys: enter sends · alt+enter or ctrl+j adds a line · esc interrupts the agent ·
ctrl+t shows or hides the model's reasoning · ↑/↓ recall earlier input · ctrl+z suspends · ctrl+d quits`

// startTurn marks the agent running, for esc to interrupt through the
// context it returns.
func (m *Model) startTurn() context.Context {
	ctx, cancel := context.WithCancel(context.Background())
	m.running, m.cancel, m.started = true, cancel, time.Now()
	return ctx
}

func (m *Model) command(text string) tea.Cmd {
	fields := strings.Fields(text)
	switch fields[0] {
	case "/clear", "/compact", "/setup", "/config", "/policy":
		if m.running {
			m.print(errStyle.Render("Wait for the agent to finish, or press esc to interrupt it."))
			return nil
		}
	}
	switch fields[0] {
	case "/help":
		m.print(helpText)
	case "/quit", "/exit":
		return m.quit()
	case "/clear":
		m.agent.Reset()
		m.print(dimStyle.Render("Started a new conversation."))
	case "/compact":
		ctx := m.startTurn()
		go m.agent.Compact(ctx)
		return m.spin.Tick
	case "/setup":
		return m.openSetup(m.saved, nil)
	case "/config":
		s := newSettings(m.saved, m.d.ConfigDir, m.width)
		m.settings = &s
	case "/policy":
		request := strings.TrimSpace(strings.TrimPrefix(text, "/policy"))
		if request == "" || request == "global" {
			paths, err := m.d.SB.Store().Create(m.d.SB.Project(), request == "global")
			if err != nil {
				m.print(errStyle.Render(Sanitize(err.Error())))
				return nil
			}
			m.print(dimStyle.Render(Sanitize("Editing " + paths[0] + " and " + paths[1] + ".")))
			return m.openEditor(paths[:]...)
		}
		m.print(userStyle.Render("❯ ") + Sanitize(text))
		ctx := m.startTurn()
		d, client, b := m.d, m.client, m.bridge
		go func() { b.send(eventMsg{agent.TurnDone{Err: policyRequest(ctx, d, client, b, request)}}) }()
		return m.spin.Tick
	case "/show", "/hide":
		if len(fields) == 1 {
			m.print(errStyle.Render("Usage: " + fields[0] + " PATH..."))
			return nil
		}
		m.showHide(fields[0] == "/show", fields[1:])
	case "/tool":
		if len(fields) != 3 || fields[1] != "edit" {
			m.print(errStyle.Render("Usage: /tool edit NAME"))
			return nil
		}
		path, err := tooldef.Create(m.d.ConfigDir, fields[2])
		if err != nil {
			m.print(errStyle.Render(Sanitize(err.Error())))
			return nil
		}
		m.print(dimStyle.Render("Editing " + path + `. A policy's tool rules decide whether the model may use it.`))
		return m.openEditor(path)
	default:
		m.print(errStyle.Render("Unknown command " + fields[0] + "; /help lists them."))
	}
	return nil
}

// printLint shows the policy's rules that can never apply.
func (m *Model) printLint() {
	pol, _ := m.d.SB.Policy()
	for _, w := range sandbox.Lint(pol) {
		m.print(styleLines(warnStyle, wrap("policy: "+Sanitize(w.Text), m.width)))
	}
}

// showHide adds a /show or /hide rule for each path, stopping at the
// first that fails.
func (m *Model) showHide(show bool, paths []string) {
	store := m.d.SB.Store()
	set, verb := store.Hide, "Hid"
	if show {
		set, verb = store.Show, "Showed"
	}
	var done []policy.Shown
	for _, p := range paths {
		r, err := set(m.d.SB.Env(), p)
		if err != nil {
			m.print(errStyle.Render(Sanitize(p + ": " + err.Error())))
			break
		}
		done = append(done, r)
	}
	if len(done) == 0 {
		return
	}
	if err := m.d.SB.Reload(); err != nil {
		m.print(errStyle.Render("The policy has an error, so the previous one stays in force: " + Sanitize(err.Error())))
		return
	}
	pol, _ := m.d.SB.Policy()
	for _, r := range done {
		what := "from the model in every mode"
		if show {
			what = "to the model: readable without asking"
			if r.Unsafe == policy.Write {
				what += ", writable once approved"
			}
		}
		m.print(okStyle.Render(Sanitize(verb + " " + r.Path + " " + what + ".")))
		expanded := policy.Expand(r.Path, m.d.SB.Home(), m.d.SB.Project())
		if show && !pol.Safe.Access(expanded).Read {
			m.print(warnStyle.Render(Sanitize("But the credential floor still blocks " + r.Path + ": " + pol.Safe.Explain(expanded, false))))
		}
	}
	m.printLint()
}

// openEditor edits files in $VISUAL or $EDITOR, then reloads the policy.
func (m *Model) openEditor(paths ...string) tea.Cmd {
	return openEditor(paths, func(err error) tea.Msg { return editorDone{err} })
}

// openEditor edits files in $VISUAL or $EDITOR, then sends done's message.
func openEditor(paths []string, done func(error) tea.Msg) tea.Cmd {
	editor := os.Getenv("VISUAL")
	if editor == "" {
		editor = os.Getenv("EDITOR")
	}
	args := strings.Fields(editor)
	if len(args) == 0 {
		args = []string{"vi"}
	}
	cmd := exec.Command(args[0], append(args[1:], paths...)...)
	return tea.ExecProcess(cmd, done)
}

func (m *Model) reloadPolicy() {
	if err := m.d.SB.Reload(); err != nil {
		m.print(errStyle.Render("The policy has an error, so the previous one stays in force: " + Sanitize(err.Error())))
		return
	}
	m.print(okStyle.Render("Loaded the policy."))
	m.printLint()
}

func (m Model) View() tea.View {
	if m.quitting {
		return tea.NewView("")
	}
	if m.setup != nil {
		return tea.NewView(m.setup.View())
	}
	if m.settings != nil {
		return tea.NewView(m.settings.View())
	}
	var b strings.Builder
	if m.running {
		if r := strings.TrimSpace(m.reasoning.String()); r != "" && m.reply.Len() == 0 && m.toolName == "" {
			b.WriteString(styleLines(thinkStyle, tailLines(wrap(Sanitize(r), m.width-2), 3)) + "\n")
		}
		if r := strings.TrimSpace(m.reply.String()); r != "" {
			b.WriteString(tailLines(markdown(Sanitize(r), m.width), max(m.height-12, 3)) + "\n")
		}
	}
	// While a question waits for the user, nothing runs: the question
	// stands in for the status line.
	if m.running && m.question == nil {
		status := "Thinking"
		if m.toolName != "" {
			status = "Running " + m.toolName
			if s := toolBrief(m.toolArgs); s != "" {
				status += ": " + s
			}
		}
		elapsed := time.Since(m.started).Round(time.Second)
		b.WriteString(m.spin.View() + " " + truncate(Sanitize(status), m.width-20) + dimStyle.Render(fmt.Sprintf(" %s · esc to interrupt", elapsed)) + "\n")
	}
	if q := m.question; q != nil {
		if q.q.Pager {
			end := min(q.top+m.pagerHeight(q), len(q.pager))
			for _, line := range q.pager[q.top:end] {
				b.WriteString(line + "\n")
			}
			rule := fmt.Sprintf("── lines %d-%d of %d ", q.top+1, end, len(q.pager)) + strings.Repeat("─", m.width)
			b.WriteString(dimStyle.Render(ansi.Truncate(rule, m.width, "")) + "\n")
		}
		var qb strings.Builder
		qb.WriteString(warnStyle.Render(diffview.Visible(q.q.Title)) + "\n")
		for i, o := range q.q.Opts {
			line := fmt.Sprintf("%d. %s", i+1, o.Label)
			qb.WriteString(menuRow(line, i == q.sel))
		}
		switch {
		case q.noting:
			qb.WriteString(q.note.View() + "\n")
			qb.WriteString(dimStyle.Render("enter sends · esc goes back"))
		case q.q.Pager:
			qb.WriteString(dimStyle.Render("↑/↓ pgup/pgdn scroll · tab and enter, or a number · esc denies"))
		default:
			qb.WriteString(dimStyle.Render("↑/↓ and enter, or a number · esc denies"))
		}
		b.WriteString(boxStyle.Width(min(m.width, 100)).Render(qb.String()) + "\n")
		return tea.NewView(b.String())
	}
	above := strings.Count(b.String(), "\n")
	in, x, y := m.inputView()
	b.WriteString(in + "\n")
	b.WriteString(m.statusLine())
	v := tea.NewView(b.String())
	if c := m.input.Cursor(); c != nil {
		c.Position.X, c.Position.Y = x, y+above
		v.Cursor = c
	}
	return v
}

func (m Model) statusLine() string {
	parts := []string{Sanitize(m.d.Config.Model)}
	if n := len(m.queued); n > 0 {
		parts = append(parts, fmt.Sprintf("%d queued", n))
	}
	if m.showThink {
		parts = append(parts, "reasoning shown")
	}
	parts = append(parts, "/help")
	return dimStyle.Render(truncate(strings.Join(parts, " · "), m.width))
}

// toolBrief describes a running tool call from its arguments.
func toolBrief(args string) string {
	var a struct {
		Path   string `json:"path"`
		Script string `json:"script"`
	}
	if err := json.Unmarshal([]byte(args), &a); err != nil {
		return ""
	}
	if a.Path != "" {
		return a.Path
	}
	return firstLine(a.Script)
}