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 }