package tui
import (
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"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
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 two at the end are
// conditional — Claude's skills are only offered 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.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
// 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 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 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 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 stepClaudeSkills:
return claudeSkillsOptions()
case stepHerdr:
return herdrOptions()
}
return nil
}
// 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
}