package tui
import (
"context"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"harness/internal/config"
"harness/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{}
type wizModels struct {
models []llm.Model
err error
}
func wizTickCmd() tea.Cmd {
return tea.Tick(frameInterval, func(time.Time) tea.Msg { return wizTick{} })
}
// fetchModels asks the endpoint what it serves. The error is carried in the
// message rather than surfaced as a failure: the wizard has a fallback list
// and must stay usable against an endpoint with no catalogue at all.
func fetchModels(c *llm.Client) tea.Cmd {
if c == nil {
return nil
}
return func() tea.Msg {
models, err := c.ListModels(context.Background())
return wizModels{models: models, err: err}
}
}
func (w *wizard) Init() tea.Cmd {
cmds := []tea.Cmd{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 wizModels:
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 {
for i, o := range opts {
if o.value == value {
return i
}
}
return 0
}
func (w *wizard) finish() {
cfg := w.cfg
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 = llm.KnownModels()
}
out := make([]wizOption, 0, len(models)+1)
for _, m := range models {
out = append(out, wizOption{
value: m.ID,
label: llm.PrettyModel(m.ID),
note: llm.PriceLabel(m.ID),
desc: m.ID,
})
}
return append(out, wizOption{
value: customModel,
label: "custom…",
desc: "type any model id, for a gateway serving something else",
})
}
func effortOptions() []wizOption {
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(config.Efforts))
for _, e := range config.Efforts {
o := wizOption{value: e, label: e, desc: desc[e]}
if e == "high" {
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
}