package tui
import (
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"codey/internal/config"
"codey/internal/llm"
)
// 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
stepDone
)
// railSteps is how many of the steps are questions, which is what the progress
// rail counts. The intro and the closing animation are not positions on it.
const railSteps = 5
// 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
// 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.
func newWizard(client *llm.Client, cur config.Config) *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(),
}
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(stepDone)
}
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)
}
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
}
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()
}
return nil
}
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
}