package tui // Drawing the setup wizard. Every function here is a pure function of the // wizard's state and its frame counter: nothing in this file decides anything, // which is what lets a test render any frame of any step on demand. import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "github.com/johannSo/codey/internal/config" ) // ---- view --------------------------------------------------------------- func (w *wizard) View() string { if w.width == 0 || w.height == 0 { return "" } switch w.anim { case animSlide: return slide(w.from, w.screen(), w.width, w.height, progress(w.frame, slideFrames)) default: return w.screen() } } // cardWidth is the width of the content column. It is capped well below a wide // terminal: a question set in 200 columns is a question nobody reads. func (w *wizard) cardWidth() int { return minInt(74, maxInt(40, w.width-8)) } // screen composes one full frame: the whole terminal, so that a slide can cut // it at any column. // // Every line is squared off to the card width on the way out — clamped if it // overruns, padded if it falls short. // // The padding is not cosmetic. lipgloss.Place centres each line independently, // so a card of ragged lines comes out as a centred blob with every row on its // own axis: a left-aligned list of options would not line up with itself. // Padding first makes the card one rectangle, which Place then centres as a // unit, and the layout inside it survives. func (w *wizard) screen() string { width := w.cardWidth() lines := strings.Split(w.card(), "\n") for i, line := range lines { line = truncateStyled(line, width) if pad := width - lipgloss.Width(line); pad > 0 { line += strings.Repeat(" ", pad) } lines[i] = line } return lipgloss.Place(w.width, w.height, lipgloss.Center, lipgloss.Center, strings.Join(lines, "\n")) } func (w *wizard) card() string { if w.step == stepDone { return w.doneCard() } if w.step == stepIntro { return w.introCard() } inner := w.cardWidth() var b strings.Builder b.WriteString(w.header(inner) + "\n\n") b.WriteString(w.rail(inner) + "\n\n") b.WriteString(styText.Render(w.question()) + "\n\n") b.WriteString(w.list(inner)) b.WriteString("\n" + w.footer(inner)) return b.String() } // header is the mark and the word "setup", present on every step so the screen // always says what it is. func (w *wizard) header(width int) string { left := styHeading.Render("codey") + styDim.Render(" setup") right := styFaint.Render(fmt.Sprintf("step %d of %d", w.ordinal(w.step), w.rails())) return justify(left, right, width) } // justify puts left at the left edge and right at the right one. // // It is spread without the leading space. The chat's status bar wants that // margin; here every line starts at the same column by construction, and one // line indented a cell further than its neighbours is exactly the kind of // wrongness that is hard to see and impossible to unsee. func justify(left, right string, width int) string { width = maxInt(width, 1) gap := width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { return truncateStyled(left, width) } return left + strings.Repeat(" ", gap) + right } // railPos is where the marker sits, as a float so it can be between nodes // during a transition. It is the step's place among the questions asked this // run, not its ordinal in the enum: a question that is skipped leaves no gap. func (w *wizard) railPos() float64 { return float64(maxInt(0, w.ordinal(w.step)-1)) } // rail is the progress indicator: four nodes, lit by how close the marker is, // with the segments between them filling as it travels. During a slide the // marker eases from where it was to where it is going, so the rail moves with // the panel rather than jumping ahead of it. func (w *wizard) rail(width int) string { pos := w.railPos() if w.anim == animSlide { pos = w.railFrom + (pos-w.railFrom)*ease(progress(w.frame, slideFrames)) } // The dashes between the nodes, sized so the rail spans the card exactly. // The remainder is handed out one cell at a time to the leftmost segments // rather than dropped, which is what left the rail a few cells short of // everything it was meant to line up with. rails := w.rails() gaps := rails - 1 seg, extra := maxInt(3, (width-rails)/gaps), (width-rails)%gaps var b strings.Builder for i := 0; i < rails; i++ { lit := clamp01(1 - absFloat(pos-float64(i))) glyph := "◇" if lit > 0.5 { glyph = "◆" } b.WriteString(lipgloss.NewStyle().Foreground(blend(colFaint, colCoral, lit)).Render(glyph)) if i == rails-1 { break } n := seg if i < extra { n++ } // The segment fills left to right as the marker crosses it. Each run // is written only when it has something in it: rendering an empty // string still emits the colour escapes around it, which is invisible // on screen but makes every rail assertion in a test a guessing game. filled := int(clamp01(pos-float64(i)) * float64(n)) if filled > 0 { b.WriteString(styCoral.Render(strings.Repeat("━", filled))) } if rest := n - filled; rest > 0 { b.WriteString(styFaint.Render(strings.Repeat("─", rest))) } } return b.String() } func (w *wizard) question() string { switch w.step { case stepModel: return "Which model should the agent use?" case stepEffort: return "How hard should it think?" case stepApprovals: return "Before it changes a file or runs a command?" case stepWebSearch: return "Should it be able to search the web?" case stepTheme: return "How should it look?" case stepClaudeCodeAuth: return "No API key is set. Use your Claude Code login instead?" case stepClaudeSkills: // The number is the offer: it says these are real, already on the // machine, and how much is being agreed to. if w.claudeSkills == 1 { return "Claude Code has a skill on this machine. Use it?" } return fmt.Sprintf("Claude Code has %d skills on this machine. Use them?", w.claudeSkills) case stepHerdr: return "Install herdr's skill, so the agent can drive herdr?" } return "" } // list renders the options, with the highlighted one carrying a breathing // marker and its own sentence of explanation. func (w *wizard) list(width int) string { if w.typing { return w.customEntry(width) } opts := w.options() if len(opts) == 0 { return styDim.Render(" " + w.spinnerWord() + " asking " + endpointLabel(w.client) + " what it serves…") } // A long catalogue is windowed rather than allowed to push the footer off // the screen. visible := maxInt(3, minInt(len(opts), w.height-14)) first := 0 if w.cursor >= visible { first = w.cursor - visible + 1 } var b strings.Builder if w.modelsErr != nil && w.step == stepModel { b.WriteString(styWarn.Render(" ⚠ ") + styDim.Render("couldn't reach "+endpointLabel(w.client)+" — showing the models this build knows") + "\n\n") } for i := first; i < minInt(len(opts), first+visible); i++ { o := opts[i] selected := i == w.cursor marker := styFaint.Render(" ◇ ") label := styDim.Render(o.label) if selected { // The marker breathes between faint and the accent, so the eye // can find the cursor on a screen it has just been dropped into. lit := 0.45 + 0.55*triangle(w.frame, breathPeriod) if w.still() { lit = 1 } marker = lipgloss.NewStyle().Foreground(blend(colFaint, colCoral, lit)).Render("▌ ◆ ") label = styText.Bold(true).Render(o.label) } line := marker + label if o.note != "" { line = justify(line, styFaint.Render(o.note), width) } b.WriteString(truncateStyled(line, width) + "\n") } if rest := len(opts) - (first + visible); rest > 0 { b.WriteString(styFaint.Render(fmt.Sprintf(" … %d more", rest)) + "\n") } if d := opts[w.cursor].desc; d != "" { // Prose, so it wraps rather than being cut: the sentence explaining an // option is the one thing on the screen you actually have to read. b.WriteString("\n") for _, line := range strings.Split(styDim.Width(width-4).Render(d), "\n") { b.WriteString(" " + line + "\n") } } return b.String() } func (w *wizard) customEntry(width int) string { hint := " The id the endpoint expects, not a display name." if w.step == stepWebSearch { hint = " {{query}} and {{count}} are filled in. No shell: the query is one argument." } var b strings.Builder b.WriteString(styDim.Render(hint) + "\n\n") b.WriteString(styBoxFocus.Width(width-4).Render(w.custom.View()) + "\n") return b.String() } // spinnerWord is the catalogue's own small animation, so a slow endpoint does // not look like a hang. func (w *wizard) spinnerWord() string { if w.still() { return "·" } return styCoral.Render(pulse.Frames[(w.frame/4)%len(pulse.Frames)]) } func (w *wizard) footer(width int) string { var keys string switch { case w.typing: keys = hint("enter") + " use it " + hint("esc") + " back to the list" default: keys = hint("↑↓") + " move " + hint("enter") + " next " + hint("esc") + " back " + hint("ctrl+c") + " cancel" } return "\n" + truncateStyled(styFaint.Render(keys), width) } // hint renders a key name in the footer's key-then-meaning grammar. func hint(k string) string { return styKey.Render(k) } // ---- intro -------------------------------------------------------------- // introCard is the first thing a new install shows: the mark assembling, the // name igniting, and one line saying what is about to happen. func (w *wizard) introCard() string { t := progress(w.frame, introFrames) var b strings.Builder // The mascot assembles a row at a time, top down. rows := len(mascot) shown := rows if !w.still() { shown = minInt(rows, int(t*float64(introFrames)/4)+1) } for i := 0; i < rows; i++ { line := strings.Repeat(" ", lipgloss.Width(mascot[i])) if i < shown { // Each row fades in over its first few frames rather than // appearing at full strength. age := clamp01((t*float64(introFrames) - float64(i*4)) / 6) line = lipgloss.NewStyle().Foreground(blend(colFaint, colCoral, age)).Render(mascot[i]) } b.WriteString(line + "\n") } b.WriteString("\n") // "codey" ignites left to right once the mark is up. titleT := clamp01((t*float64(introFrames) - 6) / 18) b.WriteString(ignite("codey", colFaint, colCoral, titleT, true) + "\n") // The tagline types itself underneath. The short form is not a truncation // of the long one: a cut sentence reads as a bug, where a shorter sentence // reads as a sentence. tagline := "let's pick your defaults — four questions, about a minute" if w.cardWidth() < len(tagline)+4 { tagline = "four questions, about a minute" } typed := tagline if !w.still() { typed = reveal(tagline, int(clamp01((t*float64(introFrames)-18)/22)*float64(len(tagline)))) } b.WriteString(styDim.Render(typed) + "\n\n") // The invitation appears only once the rest has settled, and breathes. if w.anim == animNone || w.still() { lit := 0.4 + 0.6*triangle(w.frame, breathPeriod) if w.still() { lit = 1 } prompt := lipgloss.NewStyle().Foreground(blend(colFaint, colText, lit)).Render("press enter to begin") b.WriteString(prompt + styFaint.Render(" ctrl+c to skip") + "\n") } else { b.WriteString("\n") } return b.String() } // ---- done --------------------------------------------------------------- // doneCard is the closing flourish: the four answers fly in, a bar sweeps // across, and a check lands. Then the wizard hands the screen to the chat. func (w *wizard) doneCard() string { inner := w.cardWidth() f := float64(w.frame) if w.still() { f = doneFrames } rows := [][2]string{ {"model", prettyModel(w.cfg.Model)}, {"effort", w.cfg.Effort}, {"approvals", w.cfg.Approvals}, {"theme", w.cfg.Theme}, } var b strings.Builder b.WriteString(styHeading.Render("codey") + styDim.Render(" setup") + "\n\n") for i, r := range rows { // Staggered: each row starts three frames after the one above, so // they arrive as a cascade rather than as a block. t := easeOut(clamp01((f - float64(i*3)) / 10)) if t == 0 { b.WriteString("\n") continue } // Flying in from the right: the offset shrinks to nothing. pad := int((1 - t) * 18) line := styDim.Render(fmt.Sprintf("%-11s", r[0])) + styText.Bold(true).Render(r[1]) b.WriteString(truncateStyled(strings.Repeat(" ", pad)+line, inner) + "\n") } b.WriteString("\n") b.WriteString(sweep(inner, clamp01((f-12)/18), colCoral) + "\n\n") if f >= 30 { // The check pops: it overshoots its size for a few frames. There is // only one glyph to scale, so the overshoot is spacing around it. gap := 0 if f < 34 { gap = 1 } pad := strings.Repeat(" ", gap) b.WriteString(styOK.Bold(true).Render("✓") + pad + " " + styText.Render("you're all set") + "\n") // The path is the first thing to go when there is no room for it: // what matters is that setup can be reopened, not where it is kept. tail := "saved to " + configPathLabel() + " · /setup to change it" if inner < len(tail)+4 { tail = "/setup to change it" } b.WriteString(styFaint.Render(tail) + "\n") } else { b.WriteString("\n\n") } return b.String() } // configPathLabel names the file the answers land in, shortened the way the // welcome block shortens the working root. func configPathLabel() string { p, err := config.Path() if err != nil { return "the config file" } return shortenHome(p) } func absFloat(f float64) float64 { if f < 0 { return -f } return f }