Vectrum
Sign In
846 lines · 25.0 KB · Go
package tui

import (
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"

	"github.com/johannSo/codey/internal/claudecode"
	"github.com/johannSo/codey/internal/config"
	"github.com/johannSo/codey/internal/llm"
	"github.com/johannSo/codey/internal/skills"
)

// The setup wizard: a full-screen, one-question-at-a-time interface that owns
// the whole terminal while it is open. This file is its state — what has been
// answered, what is animating, what a key means. wizard_view.go draws it.
//
// It is a separate model rather than another phase of the chat model. The
// chat model is a state machine about a running agent — turns, approvals,
// interrupts — and none of that is true while the wizard is up. Keeping them
// apart means the wizard cannot leave the chat in a half-state, and the chat's
// key handling cannot fire under a screen it was never written for.

type wizStep int

const (
	stepIntro wizStep = iota
	stepModel
	stepEffort
	stepApprovals
	stepWebSearch
	stepTheme
	stepClaudeCodeAuth
	stepClaudeSkills
	stepHerdr
	stepDone
)

// baseRailSteps is how many of the steps are always questions, which is what
// the progress rail counts. The intro and the closing animation are not
// positions on it.
const baseRailSteps = 5

// questions is the steps this run will ask, in order. The three at the end are
// conditional — the Claude Code login is only asked about where it could be
// borrowed, Claude's skills only on a machine that has some, herdr's only
// where herdr is — so the rail, the "step n of m" counter and the
// next/previous arithmetic all read this list rather than the step's own
// ordinal. With one conditional step it was enough to keep it last; with two,
// the second is in the middle whenever the first is skipped, and a node the
// marker jumps over is worse than no node.
func (w *wizard) questions() []wizStep {
	q := []wizStep{stepModel, stepEffort, stepApprovals, stepWebSearch, stepTheme}
	if w.offerClaudeCodeAuth {
		q = append(q, stepClaudeCodeAuth)
	}
	if w.claudeSkills > 0 {
		q = append(q, stepClaudeSkills)
	}
	if w.offerHerdr {
		q = append(q, stepHerdr)
	}
	return q
}

// rails is how many nodes the rail has for this run: one per question asked,
// rather than one per question that exists and a node nobody will ever stand
// on.
func (w *wizard) rails() int { return len(w.questions()) }

// ordinal is a step's position on the rail, counting from one. The intro is
// before the first node and the closing screen is on the last, which is where
// the marker should sit while the answers fly in.
func (w *wizard) ordinal(s wizStep) int {
	q := w.questions()
	if s == stepDone {
		return len(q)
	}
	for i, step := range q {
		if step == s {
			return i + 1
		}
	}
	return 0
}

// next is the question after s, or the closing screen after the last one.
func (w *wizard) next(s wizStep) wizStep {
	q := w.questions()
	if i := w.ordinal(s); i < len(q) {
		return q[i]
	}
	return stepDone
}

// prev is the question before s, or the intro before the first one.
func (w *wizard) prev(s wizStep) wizStep {
	q := w.questions()
	if i := w.ordinal(s); i > 1 {
		return q[i-2]
	}
	return stepIntro
}

// Frame budgets. They are counts rather than durations so that every animation
// is addressable by frame number in a test.
const (
	introFrames = 40
	slideFrames = 9
	doneFrames  = 46
	// breathPeriod is how long the selected row takes to go dim and bright
	// again. Slow: a marker that blinks at you competes with the text it is
	// meant to be pointing at.
	breathPeriod = 44
)

type animKind int

const (
	animNone animKind = iota
	animIntro
	animSlide
	animDone
)

// wizOption is one answer on offer. label is what it is, note is the fact that
// decides between them — a price, a default — and desc is the sentence shown
// only for the highlighted row, so the list stays scannable.
type wizOption struct {
	value string
	label string
	note  string
	desc  string
}

type wizard struct {
	width, height int

	step   wizStep
	cursor int
	// chosen remembers the cursor per step, so stepping back lands on what you
	// picked rather than at the top of the list.
	chosen map[wizStep]int

	cfg    config.Config
	client *llm.Client

	// The catalogue, and what to say if it could not be had.
	models    []llm.Model
	modelsErr error
	loading   bool

	// custom is the "type any model id" entry, for gateways serving models
	// this build has never heard of.
	custom textinput.Model
	typing bool

	// Animation state. frame counts from the start of the current animation.
	anim  animKind
	frame int
	from  string
	// railFrom is where the progress rail was when the current slide began.
	railFrom float64
	// reduced collapses every animation to its final frame.
	reduced bool

	// enteredTheme is the palette in force when the wizard opened. Cancelling
	// puts it back, because the theme step previews by applying for real.
	enteredTheme string

	// claudeSkills is how many skills Claude Code has on this machine, and so
	// whether the question about them is asked at all. It is a count rather
	// than a flag because the question says it: "the 14 skills" is an offer,
	// where "some skills" is a mystery.
	claudeSkills int

	// offerClaudeCodeAuth is whether to ask about borrowing Claude Code's
	// login: true only where no key or token was supplied, the endpoint is
	// Anthropic's, and Claude Code is logged in here. Someone with an API key
	// has nothing to decide, and is not asked.
	offerClaudeCodeAuth bool

	// offerHerdr is whether this machine has herdr, and so whether the skill
	// question is asked at all.
	offerHerdr bool
	// installSkill is the answer to it. It is not part of the config: what the
	// wizard saves is preferences, and this is a one-off act to be carried out
	// once — writing "I said yes in March" into the file would be a standing
	// instruction to re-download it on every launch.
	installSkill bool

	// result is set once the answers are final; cancelled once they are
	// abandoned. The parent model polls both after each update.
	result    *config.Config
	cancelled bool
}

// newWizard builds the wizard, preselecting whatever is already in force so
// that pressing enter four times is a no-op rather than a reset.
//
// claudeSkills is how many skills Claude Code has on this machine; zero means
// the question about them is not asked. The count is not resolved here
// because it is a fact about the disk, and the wizard is told facts about the
// machine rather than finding them out, so that a test can assert on a machine
// it made up.
func newWizard(client *llm.Client, cur config.Config, claudeSkills int, offerHerdr bool) *wizard {
	ti := textinput.New()
	ti.Placeholder = "model id, e.g. claude-sonnet-5"
	ti.Prompt = "› "
	ti.CharLimit = 200

	w := &wizard{
		client:       client,
		cfg:          cur,
		chosen:       map[wizStep]int{},
		custom:       ti,
		loading:      client != nil,
		anim:         animIntro,
		enteredTheme: currentTheme.name,
		reduced:      reducedMotion(),
		claudeSkills: claudeSkills,
		offerHerdr:   offerHerdr,
	}
	if w.cfg.Theme == "" {
		w.cfg.Theme = currentTheme.name
	}
	if w.cfg.Approvals == "" {
		w.cfg.Approvals = config.ApprovalsAsk
	}
	if w.cfg.WebSearch == "" {
		w.cfg.WebSearch = config.WebSearchNone
	}
	return w
}

// reducedMotion reports whether this terminal should be spared the animations.
//
// NO_COLOR is honoured because an animation built out of colour transitions is
// mostly noise without them, and a terminal that cannot report its type is
// assumed to be something being scripted or logged rather than watched.
func reducedMotion() bool {
	if os.Getenv("NO_COLOR") != "" {
		return true
	}
	switch os.Getenv("TERM") {
	case "", "dumb":
		return true
	}
	return false
}

// tooSmall reports whether the terminal cannot hold the wizard's layout. The
// animations are dropped before the content is: a cramped but readable
// question beats a beautifully animated one that does not fit.
func (w *wizard) tooSmall() bool { return w.width < 60 || w.height < 20 }

func (w *wizard) still() bool { return w.reduced || w.tooSmall() }

// ---- messages -----------------------------------------------------------

type wizTick struct{}

func wizTickCmd() tea.Cmd {
	return tea.Tick(frameInterval, func(time.Time) tea.Msg { return wizTick{} })
}

func (w *wizard) Init() tea.Cmd {
	var cmds []tea.Cmd
	if w.client != nil {
		cmds = append(cmds, fetchModels(w.client))
	}
	if !w.still() {
		cmds = append(cmds, wizTickCmd())
	} else {
		// Nothing will advance the intro, so it is already over.
		w.anim, w.frame = animNone, introFrames
	}
	return tea.Batch(cmds...)
}

// ---- update -------------------------------------------------------------

func (w *wizard) Update(msg tea.Msg) tea.Cmd {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		w.width, w.height = msg.Width, msg.Height
		w.custom.Width = maxInt(20, w.cardWidth()-8)
		return nil

	case modelsMsg:
		w.loading = false
		w.models, w.modelsErr = msg.models, msg.err
		// The catalogue is what the model step's rows are made of, so the
		// preselection can only be resolved now. It is resolved against the
		// model options specifically: the step on screen when this lands is
		// usually still the intro, and asking that step for its options would
		// preselect row zero of a list nobody is looking at.
		w.chosen[stepModel] = w.indexOf(w.modelOptions(), w.cfg.Model)
		if w.step == stepModel {
			w.cursor = w.chosen[stepModel]
		}
		return nil

	case wizTick:
		return w.tick()

	case tea.KeyMsg:
		return w.onKey(msg)
	}
	return nil
}

// tick advances the clock. It keeps scheduling frames for as long as the
// wizard is open, not only while something is animating, because the selected
// row breathes on every step — that is a redraw of one screen at 30fps for the
// minute somebody spends here, and it stops the moment the wizard closes.
func (w *wizard) tick() tea.Cmd {
	if w.still() {
		return nil
	}
	w.frame++

	switch w.anim {
	case animIntro:
		if w.frame >= introFrames {
			// The intro's last beat is an invitation to press a key, which
			// breathes on its own; the animation proper is over.
			w.anim = animNone
		}
	case animSlide:
		if w.frame >= slideFrames {
			w.anim, w.from = animNone, ""
		}
	case animDone:
		if w.frame >= doneFrames {
			w.finish()
			return nil
		}
	}
	return wizTickCmd()
}

func (w *wizard) onKey(msg tea.KeyMsg) tea.Cmd {
	key := msg.String()

	if key == "ctrl+c" {
		w.cancel()
		return nil
	}

	// A key during an animation skips to the end of it rather than being
	// swallowed. Nobody should have to wait out a flourish twice.
	if w.anim != animNone && w.anim != animSlide {
		if w.anim == animDone {
			w.finish()
			return nil
		}
		w.anim, w.frame = animNone, introFrames
		return nil
	}

	if w.typing {
		return w.onTypingKey(msg, key)
	}

	switch key {
	case "up", "k", "shift+tab":
		w.move(-1)
		return nil
	case "down", "j", "tab":
		w.move(1)
		return nil

	case "enter", "right", "l", " ":
		return w.advance()

	case "esc", "left", "h":
		return w.back()
	}
	return nil
}

// startTyping opens the free-text box on the current step. Two steps have an
// answer this build cannot enumerate — a model id only the endpoint knows, and
// a search command only the user knows — and they share one box rather than
// two, so the key handling below is written once.
func (w *wizard) startTyping(placeholder, value string) tea.Cmd {
	w.typing = true
	w.custom.Placeholder = placeholder
	w.custom.SetValue(value)
	w.custom.CursorEnd()
	return w.custom.Focus()
}

func (w *wizard) onTypingKey(msg tea.KeyMsg, key string) tea.Cmd {
	switch key {
	case "enter":
		v := strings.TrimSpace(w.custom.Value())
		if v == "" {
			return nil
		}
		w.typing = false
		w.custom.Blur()
		switch w.step {
		case stepModel:
			w.cfg.Model = v
			return w.goTo(stepEffort)
		case stepWebSearch:
			w.cfg.WebSearch = config.WebSearchCustom
			// The shipped default is stored as "no preference" rather than as
			// a copy of itself, so that someone who pressed enter through this
			// screen picks up a better command line in a later build instead
			// of being pinned to today's by an answer they never gave.
			if v == config.DefaultSearchCommand {
				v = ""
			}
			w.cfg.SearchCommand = v
			return w.goTo(stepTheme)
		}
		return nil
	case "esc":
		w.typing = false
		w.custom.Blur()
		return nil
	}
	var cmd tea.Cmd
	w.custom, cmd = w.custom.Update(msg)
	return cmd
}

func (w *wizard) move(d int) {
	opts := w.options()
	if len(opts) == 0 {
		return
	}
	w.cursor = (w.cursor + d + len(opts)) % len(opts)
	w.chosen[w.step] = w.cursor

	// The theme step previews by applying the palette for real: reading a
	// swatch tells you nothing about what a transcript looks like in it.
	if w.step == stepTheme {
		applyTheme(opts[w.cursor].value)
	}
}

// advance records the answer on the current step and moves to the next one.
func (w *wizard) advance() tea.Cmd {
	switch w.step {
	case stepIntro:
		return w.goTo(stepModel)

	case stepModel:
		opts := w.options()
		if len(opts) == 0 {
			return nil // the catalogue has not landed yet
		}
		if opts[w.cursor].value == customModel {
			return w.startTyping("model id, e.g. claude-sonnet-5", "")
		}
		w.cfg.Model = opts[w.cursor].value
		return w.goTo(stepEffort)

	case stepEffort:
		w.cfg.Effort = w.options()[w.cursor].value
		return w.goTo(stepApprovals)

	case stepApprovals:
		w.cfg.Approvals = w.options()[w.cursor].value
		return w.goTo(stepWebSearch)

	case stepWebSearch:
		v := w.options()[w.cursor].value
		if v == config.WebSearchCustom {
			// The command is shown filled in rather than blank: the answer
			// most people want is the default, and a box they have to type a
			// working ddgs invocation into from memory is a box they will
			// escape out of.
			return w.startTyping(config.DefaultSearchCommand, w.cfg.SearchCommandLine())
		}
		w.cfg.WebSearch = v
		return w.goTo(stepTheme)

	case stepTheme:
		w.cfg.Theme = w.options()[w.cursor].value
		return w.goTo(w.next(stepTheme))

	case stepClaudeCodeAuth:
		w.cfg.ClaudeCodeAuth = w.options()[w.cursor].value
		return w.goTo(w.next(stepClaudeCodeAuth))

	case stepClaudeSkills:
		w.cfg.ClaudeSkills = w.options()[w.cursor].value
		return w.goTo(w.next(stepClaudeSkills))

	case stepHerdr:
		w.installSkill = w.options()[w.cursor].value == herdrInstall
		return w.goTo(w.next(stepHerdr))
	}
	return nil
}

func (w *wizard) back() tea.Cmd {
	switch w.step {
	case stepIntro:
		// Nothing has been chosen yet, so leaving is free.
		w.cancel()
		return nil
	case stepModel:
		return w.goTo(stepIntro)
	case stepEffort:
		return w.goTo(stepModel)
	case stepApprovals:
		return w.goTo(stepEffort)
	case stepWebSearch:
		return w.goTo(stepApprovals)
	case stepTheme:
		return w.goTo(stepWebSearch)
	case stepClaudeCodeAuth, stepClaudeSkills, stepHerdr:
		return w.goTo(w.prev(w.step))
	}
	return nil
}

// goTo starts the transition to a step, capturing the screen being left so it
// can be slid out from under the new one.
func (w *wizard) goTo(next wizStep) tea.Cmd {
	if w.still() {
		w.step = next
		w.restoreCursor()
		if next == stepDone {
			w.finish()
		}
		return nil
	}

	w.from = w.screen()
	w.railFrom = w.railPos()
	w.step = next
	w.restoreCursor()

	if next == stepDone {
		w.anim, w.frame = animDone, 0
	} else {
		w.anim, w.frame = animSlide, 0
	}
	return wizTickCmd()
}

// restoreCursor puts the highlight back on what is already chosen for the step
// being entered.
func (w *wizard) restoreCursor() {
	opts := w.options()
	if len(opts) == 0 {
		w.cursor = 0
		return
	}
	if i, ok := w.chosen[w.step]; ok && i < len(opts) {
		w.cursor = i
		return
	}
	w.cursor = w.indexOf(opts, w.current())
	w.chosen[w.step] = w.cursor
}

// current is the configured value for the step being shown, used to preselect.
func (w *wizard) current() string {
	switch w.step {
	case stepModel:
		return w.cfg.Model
	case stepEffort:
		return w.cfg.Effort
	case stepApprovals:
		return w.cfg.Approvals
	case stepWebSearch:
		return w.cfg.WebSearchMode()
	case stepTheme:
		return w.cfg.Theme
	case stepClaudeCodeAuth:
		return claudeCodeAuthMode(w.cfg)
	case stepClaudeSkills:
		return w.cfg.ClaudeSkillsMode()
	}
	return ""
}

func (w *wizard) indexOf(opts []wizOption, value string) int {
	return optionIndex(opts, value)
}

// optionIndex is where a value sits in a list of options, or the top of the
// list when it is not in it at all — which is what preselecting a setting this
// build no longer offers has to fall back to. The settings panel cycles the
// same lists, so the lookup is shared rather than written twice.
func optionIndex(opts []wizOption, value string) int {
	for i, o := range opts {
		if o.value == value {
			return i
		}
	}
	return 0
}

// finish seals the answers. The marker is stamped here rather than by
// whatever saves the file: it means "these questions were all asked and
// answered", which is a thing only the wizard is in a position to say.
func (w *wizard) finish() {
	cfg := w.cfg
	cfg.Setup = config.SetupDone
	w.result = &cfg
}

// cancel abandons the wizard and undoes the one change it makes as you go.
func (w *wizard) cancel() {
	applyTheme(w.enteredTheme)
	w.cancelled = true
}

// ---- options ------------------------------------------------------------

// customModel is the sentinel for the "type any model id" row. It is not a
// valid model id, so it can never be mistaken for one.
const customModel = "\x00custom"

func (w *wizard) options() []wizOption {
	switch w.step {
	case stepModel:
		return w.modelOptions()
	case stepEffort:
		return effortOptions()
	case stepApprovals:
		return approvalOptions()
	case stepWebSearch:
		return webSearchOptions()
	case stepTheme:
		return themeOptions()
	case stepClaudeCodeAuth:
		return claudeCodeAuthOptions()
	case stepClaudeSkills:
		return claudeSkillsOptions()
	case stepHerdr:
		return herdrOptions()
	}
	return nil
}

// claudeCodeAuthOptions are the two answers to "borrow Claude Code's login when
// there is no API key".
//
// No is first and the default, and it is the one place where the default is
// not merely the cautious reading but the required one: the login is issued
// to Claude Code, and using it from another client is outside Anthropic's
// terms for it. Both rows say so, because the cursor opens on no and the
// sentence about terms has to be on screen before anyone moves it to yes.
func claudeCodeAuthOptions() []wizOption {
	return []wizOption{{
		value: config.ClaudeCodeAuthOff,
		label: "No, I'll use an API key",
		note:  "default",
		desc:  "Codey uses ANTHROPIC_API_KEY, or a gateway's token. Claude Code's login is issued to Claude Code; using it from another client is outside Anthropic's terms for it.",
	}, {
		value: config.ClaudeCodeAuthOn,
		label: "Yes, use my Claude Code login",
		note:  "this machine only",
		desc:  "borrows ~/" + claudecode.Filename + " when no key is set, against Anthropic's own endpoint only. It is issued to Claude Code, and using it from another client is outside Anthropic's terms for it.",
	}}
}

// claudeCodeAuthMode reads a config's answer as one of the two words, so no
// caller has to spell the empty-means-off rule out again.
func claudeCodeAuthMode(cfg config.Config) string {
	if cfg.ClaudeCodeAuthEnabled() {
		return config.ClaudeCodeAuthOn
	}
	return config.ClaudeCodeAuthOff
}

// claudeSkillsOptions are the three things to do with the skills Claude Code
// has on this machine, in the config's order: nothing, read them in place,
// take a copy.
//
// Off is first and the default, as with web search and for the same reason: a
// skill is a page of instructions the model will follow, and it should get
// loaded because someone said so. The other two differ in who owns the files
// afterwards, which is what their descriptions are about — "in place" follows
// a plugin update, "copy" survives one.
func claudeSkillsOptions() []wizOption {
	return []wizOption{{
		value: config.ClaudeSkillsOff,
		label: "Leave them alone",
		note:  "default",
		desc:  "Codey reads only its own skills, from ~/" + skills.Dirname + " and the project's.",
	}, {
		value: config.ClaudeSkillsUse,
		label: "Use them in place",
		note:  "follows updates",
		desc:  "reads Claude Code's skills where they are, on every start, so an updated plugin is an updated skill. Codey's own skill wins when two share a name.",
	}, {
		value: config.ClaudeSkillsCopy,
		label: "Copy them once",
		note:  "~/" + skills.Dirname,
		desc:  "copies them into ~/" + skills.Dirname + ", where they are yours to edit and unaffected by plugin updates. A skill already there is left alone.",
	}}
}

// claudeSkillsCopyNote is what the transcript says after the copy the wizard
// asked for. A failure names /setup because the answer is already saved, and
// running it again is how the copy gets another go; a copy of nothing says so
// rather than "0 skills", because the usual reason is that they were all
// copied last time and left alone this time, which is the copy working.
func claudeSkillsCopyNote(n int, err error) string {
	switch {
	case err != nil:
		return "could not copy Claude Code's skills: " + err.Error() + " — /setup to try again"
	case n == 0:
		return "nothing new to copy from Claude Code — every skill it has is already in ~/" + skills.Dirname
	case n == 1:
		return "copied 1 of Claude Code's skills into ~/" + skills.Dirname + " — it is a personal skill from the next start"
	}
	return fmt.Sprintf("copied %d of Claude Code's skills into ~/%s — they are personal skills from the next start", n, skills.Dirname)
}

// The two answers to the herdr step. They are sentinels rather than settings:
// nothing is stored, so the only job these values have is to be told apart.
const (
	herdrInstall = "\x00install"
	herdrSkip    = "\x00skip"
)

// herdrOptions is the yes/no of installing herdr's agent skill.
//
// Yes is first and preselected. The usual rule here is that pressing enter
// through the wizard changes nothing, and this is the one step that breaks it
// — so the row says exactly what will be written and where, and the notice
// afterwards names the file. A step that has to be hunted for is not worth
// having, and the step only appears at all on a machine that has herdr.
func herdrOptions() []wizOption {
	return []wizOption{
		{
			value: herdrInstall,
			label: "yes, install it",
			note:  "~/" + skills.Dirname + "/herdr",
			desc:  "downloads herdr's skill so the agent can split panes, run commands in them, read their output, and wait on other agents",
		},
		{
			value: herdrSkip,
			label: "no",
			desc:  "Codey still reports its state to herdr; this is only about driving herdr from the agent",
		},
	}
}

func (w *wizard) modelOptions() []wizOption {
	if w.loading {
		return nil
	}
	models := w.models
	if len(models) == 0 {
		models = pricedModels()
	}
	out := make([]wizOption, 0, len(models)+1)
	for _, m := range models {
		label := m.DisplayName
		if label == "" {
			label = prettyModel(m.ID)
		}
		out = append(out, wizOption{
			value: m.ID,
			label: label,
			note:  priceLabel(m.ID),
			desc:  modelDetail(m),
		})
	}
	return append(out, wizOption{
		value: customModel,
		label: "custom…",
		desc:  "type any model id, for a gateway serving something else",
	})
}

func effortOptions() []wizOption {
	// The levels come from llm, which is where the API's own list lives; only
	// the sentences about them belong to the wizard.
	desc := map[string]string{
		"low":    "Answers sooner and spends less. Good for small, well-specified edits.",
		"medium": "A middle setting, for work that needs a little thinking but not much.",
		"high":   "What the API does if you say nothing. The right default for most work.",
		"xhigh":  "Thinks longer before acting. Worth it on unfamiliar code.",
		"max":    "As much deliberation as the model will do. Slowest and priciest.",
	}
	out := make([]wizOption, 0, len(llm.EffortLevels))
	for _, e := range llm.EffortLevels {
		o := wizOption{value: e, label: e, desc: desc[e]}
		if e == defaultEffort {
			o.note = "default"
		}
		out = append(out, o)
	}
	return out
}

func approvalOptions() []wizOption {
	return []wizOption{{
		value: config.ApprovalsAsk,
		label: "Ask me first",
		note:  "recommended",
		desc:  "bash, edit_file and write_file stop and wait for you. Reads run unattended.",
	}, {
		value: config.ApprovalsAcceptAll,
		label: "Accept everything",
		desc:  "tool calls run without asking. Only against a throwaway clone or a container.",
	}}
}

// webSearchOptions are the three answers to "can it look things up". They are
// ordered by how far each one lets the agent reach: nowhere, Anthropic's
// servers, this machine's network.
func webSearchOptions() []wizOption {
	return []wizOption{{
		value: config.WebSearchNone,
		label: "No web access",
		note:  "default",
		desc:  "the agent works from the repository and what it already knows.",
	}, {
		value: config.WebSearchClaude,
		label: "Claude's own search",
		note:  "no setup",
		desc:  "Anthropic runs the search and bills it. Needs an endpoint that offers the hosted tool.",
	}, {
		value: config.WebSearchCustom,
		label: "A command on this machine",
		note:  "ddgs",
		desc:  "runs a search command here and fetches pages itself. Every call asks you first.",
	}}
}

func themeOptions() []wizOption {
	out := make([]wizOption, 0, len(themes))
	for _, t := range themes {
		out = append(out, wizOption{value: t.name, label: t.name, desc: t.blurb})
	}
	return out
}