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 }