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 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
}