Vectrum
Sign In
550 lines · 13.8 KB · Go
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
	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 = 4

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

func (w *wizard) onTypingKey(msg tea.KeyMsg, key string) tea.Cmd {
	switch key {
	case "enter":
		id := strings.TrimSpace(w.custom.Value())
		if id == "" {
			return nil
		}
		w.typing = false
		w.custom.Blur()
		w.cfg.Model = id
		return w.goTo(stepEffort)
	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 {
			w.typing = true
			w.custom.SetValue("")
			return w.custom.Focus()
		}
		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(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 stepTheme:
		return w.goTo(stepApprovals)
	}
	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 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 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.",
	}}
}

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
}