The setup screens list the parts of an install: control plane, store, model mirror, this machine as a node, starter model, pictures and 3D, the judge, firewall, notifications, limits, on-demand turns and an offline bundle. Each says what it is or what is missing without it. Space turns a part on or off; Enter opens its settings in a dialog that explains it. A plan page lists what is installed and what is left out, and the same install as a command. Every setting is a server init flag too, written into server.yaml when init writes it. With three machines, the two database machines become control planes too, standing by (server add's steps), with an optional floating address. The mirror's management API answers all three control planes, so a standby that comes to lead can still fetch models. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
323 lines
7.7 KiB
Go
323 lines
7.7 KiB
Go
package cli
|
||
|
||
import (
|
||
"strings"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/charmbracelet/x/ansi"
|
||
)
|
||
|
||
// A form in a dialog: text to type, one of a few choices, a thing on or off,
|
||
// and headings between them. The setup's settings for each part are one of
|
||
// these, opened over the list of parts.
|
||
|
||
type fieldKind int
|
||
|
||
const (
|
||
fkText fieldKind = iota
|
||
// fkChoice is one of Options, changed with ← →.
|
||
fkChoice
|
||
// fkToggle is on or off, changed with Space.
|
||
fkToggle
|
||
// fkHeading is a title between fields; it takes no focus.
|
||
fkHeading
|
||
)
|
||
|
||
type setupField struct {
|
||
Label string
|
||
// Hint is said under the field; Notes, for a choice, under each option
|
||
// as it is chosen.
|
||
Hint string
|
||
Notes []string
|
||
Kind fieldKind
|
||
|
||
Text textField
|
||
Options []string
|
||
Choice int
|
||
On bool
|
||
// Show, when set, says whether the field is shown, and so whether it
|
||
// counts: a directory for a mirror kept elsewhere is neither.
|
||
Show func() bool
|
||
}
|
||
|
||
func textSetupField(label, value, placeholder, hint string) *setupField {
|
||
t := newTextField()
|
||
t.SetValue(value)
|
||
t.Placeholder, t.CharLimit, t.Width = placeholder, 800, 40
|
||
return &setupField{Label: label, Hint: hint, Kind: fkText, Text: t}
|
||
}
|
||
|
||
func choiceField(label string, options []string, choice int, hint string) *setupField {
|
||
return &setupField{Label: label, Hint: hint, Kind: fkChoice, Options: options, Choice: choice}
|
||
}
|
||
|
||
func toggleField(label string, on bool, hint string) *setupField {
|
||
return &setupField{Label: label, Hint: hint, Kind: fkToggle, On: on}
|
||
}
|
||
|
||
func headingField(label string) *setupField {
|
||
return &setupField{Label: label, Kind: fkHeading}
|
||
}
|
||
|
||
func (f *setupField) shown() bool { return f.Show == nil || f.Show() }
|
||
|
||
func (f *setupField) Value() string { return strings.TrimSpace(f.Text.Value()) }
|
||
|
||
// setupDialog is a form over the screen, with what it is for above it.
|
||
type setupDialog struct {
|
||
Title string
|
||
About string
|
||
Fields []*setupField
|
||
focus int
|
||
}
|
||
|
||
// focusable is whether a field can have the focus now.
|
||
func (d *setupDialog) focusable(i int) bool {
|
||
f := d.Fields[i]
|
||
return f.Kind != fkHeading && f.shown()
|
||
}
|
||
|
||
// first puts the focus on the first field that takes it.
|
||
func (d *setupDialog) first() {
|
||
d.focus = -1
|
||
d.move(1)
|
||
}
|
||
|
||
// focusOn puts the focus on a field, as when it is what is wrong.
|
||
func (d *setupDialog) focusOn(f *setupField) {
|
||
for i, x := range d.Fields {
|
||
if x == f {
|
||
d.focus = i
|
||
}
|
||
}
|
||
d.blurAll()
|
||
}
|
||
|
||
func (d *setupDialog) move(dir int) {
|
||
n := len(d.Fields)
|
||
for range n {
|
||
d.focus = (d.focus + dir + n) % n
|
||
if d.focusable(d.focus) {
|
||
break
|
||
}
|
||
}
|
||
d.blurAll()
|
||
}
|
||
|
||
// blurAll leaves only the focused text field with a cursor.
|
||
func (d *setupDialog) blurAll() {
|
||
for i, f := range d.Fields {
|
||
if f.Kind != fkText {
|
||
continue
|
||
}
|
||
if i == d.focus {
|
||
f.Text.Focus()
|
||
} else {
|
||
f.Text.Blur()
|
||
}
|
||
}
|
||
}
|
||
|
||
// hasFields is whether there is anything to set, rather than only to read.
|
||
func (d *setupDialog) hasFields() bool {
|
||
for i := range d.Fields {
|
||
if d.focusable(i) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// lastFocusable is the last field that takes the focus now.
|
||
func (d *setupDialog) lastFocusable() int {
|
||
for i := len(d.Fields) - 1; i >= 0; i-- {
|
||
if d.focusable(i) {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// key takes a key and says whether the dialog is done: Esc, or Enter on
|
||
// the last field.
|
||
func (d *setupDialog) key(k tea.KeyMsg) (done bool) {
|
||
switch k.Type {
|
||
case tea.KeyEsc:
|
||
return true
|
||
case tea.KeyEnter:
|
||
if !d.hasFields() || d.focus == d.lastFocusable() {
|
||
return true
|
||
}
|
||
d.move(1)
|
||
return false
|
||
case tea.KeyTab, tea.KeyDown:
|
||
if d.hasFields() {
|
||
d.move(1)
|
||
}
|
||
return false
|
||
case tea.KeyShiftTab, tea.KeyUp:
|
||
if d.hasFields() {
|
||
d.move(-1)
|
||
}
|
||
return false
|
||
}
|
||
if d.focus < 0 || d.focus >= len(d.Fields) || !d.focusable(d.focus) {
|
||
return false
|
||
}
|
||
f := d.Fields[d.focus]
|
||
switch f.Kind {
|
||
case fkText:
|
||
f.Text, _ = f.Text.Update(k)
|
||
case fkChoice:
|
||
switch k.String() {
|
||
case "left", "h":
|
||
f.Choice = (f.Choice + len(f.Options) - 1) % len(f.Options)
|
||
case "right", "l", " ":
|
||
f.Choice = (f.Choice + 1) % len(f.Options)
|
||
}
|
||
case fkToggle:
|
||
if k.String() == " " || k.String() == "x" {
|
||
f.On = !f.On
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// dialogWidth is how wide the text in a dialog is wrapped.
|
||
const dialogWidth = 66
|
||
|
||
// view draws the form in at most maxLines lines, the part around the focus
|
||
// when it is longer: a long form scrolls as the focus moves.
|
||
func (d *setupDialog) view(maxLines int) string {
|
||
var lines []string
|
||
add := func(s string) { lines = append(lines, strings.Split(s, "\n")...) }
|
||
if d.About != "" {
|
||
add(paragraphs(d.About, dialogWidth, ""))
|
||
}
|
||
if !d.hasFields() {
|
||
add("")
|
||
add(sDim.Render("Nothing to set for this part. Esc or Enter to go back."))
|
||
return clipLines(lines, 0, 0, maxLines)
|
||
}
|
||
add("")
|
||
focusStart, focusEnd := 0, 0
|
||
for i, f := range d.Fields {
|
||
if !f.shown() {
|
||
continue
|
||
}
|
||
focused := i == d.focus
|
||
if focused {
|
||
focusStart = len(lines)
|
||
}
|
||
label := func(s string) string {
|
||
if focused {
|
||
return fSelected.Render("› " + s)
|
||
}
|
||
return " " + s
|
||
}
|
||
switch f.Kind {
|
||
case fkHeading:
|
||
add("")
|
||
add(" " + lipgloss.NewStyle().Bold(true).Render(f.Label))
|
||
continue
|
||
case fkText:
|
||
add(label(f.Label))
|
||
add(" " + f.Text.View())
|
||
case fkChoice:
|
||
var opts []string
|
||
for j, o := range f.Options {
|
||
if j == f.Choice {
|
||
opts = append(opts, "("+sOK.Render("•")+") "+o)
|
||
} else {
|
||
opts = append(opts, "( ) "+sDim.Render(o))
|
||
}
|
||
}
|
||
add(label(f.Label))
|
||
// Side by side while they fit, else one a line.
|
||
if row := strings.Join(opts, " "); ansi.StringWidth(row) <= dialogWidth-4 {
|
||
add(" " + row)
|
||
} else {
|
||
for _, o := range opts {
|
||
add(" " + o)
|
||
}
|
||
}
|
||
if f.Choice < len(f.Notes) && f.Notes[f.Choice] != "" {
|
||
add(" " + sDim.Render(wrap(f.Notes[f.Choice], dialogWidth-4, " ")))
|
||
}
|
||
case fkToggle:
|
||
box := "[ ]"
|
||
if f.On {
|
||
box = "[" + sOK.Render("x") + "]"
|
||
}
|
||
add(label(box) + " " + f.Label)
|
||
}
|
||
if f.Hint != "" {
|
||
add(" " + sDim.Render(wrap(f.Hint, dialogWidth-4, " ")))
|
||
}
|
||
if focused {
|
||
focusEnd = len(lines) - 1
|
||
}
|
||
if f.Kind != fkToggle {
|
||
add("")
|
||
}
|
||
}
|
||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||
lines = lines[:len(lines)-1]
|
||
}
|
||
return clipLines(lines, focusStart, focusEnd, maxLines)
|
||
}
|
||
|
||
// clipLines is at most n of lines, keeping lines from to through in sight
|
||
// as far as they fit, with a mark at an edge that has more beyond it.
|
||
func clipLines(lines []string, from, through, n int) string {
|
||
if n <= 2 || len(lines) <= n {
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
top := 0
|
||
// The last line shown is a mark when there is more below: the focus
|
||
// ends a line above it.
|
||
if through > n-2 {
|
||
top = through - (n - 2)
|
||
}
|
||
// Its first line wins over its last, under the mark above.
|
||
if from > 0 && top > from-1 {
|
||
top = from - 1
|
||
}
|
||
top = max(0, min(top, len(lines)-n))
|
||
out := append([]string{}, lines[top:top+n]...)
|
||
if top > 0 {
|
||
out[0] = sDim.Render(" ↑ more")
|
||
}
|
||
if top+n < len(lines) {
|
||
out[n-1] = sDim.Render(" ↓ more")
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// paragraphs wraps text paragraph by paragraph, blank lines kept between.
|
||
func paragraphs(s string, width int, indent string) string {
|
||
var out []string
|
||
for _, p := range strings.Split(s, "\n\n") {
|
||
out = append(out, indent+wrap(p, width, indent))
|
||
}
|
||
return strings.Join(out, "\n\n")
|
||
}
|
||
|
||
// setupDialogKeys is the key bar under a dialog.
|
||
func setupDialogKeys(d *setupDialog) string {
|
||
if !d.hasFields() {
|
||
return "Esc or Enter back"
|
||
}
|
||
keys := "Tab/↑↓ move"
|
||
if d.focus >= 0 && d.focus < len(d.Fields) {
|
||
switch d.Fields[d.focus].Kind {
|
||
case fkChoice:
|
||
keys += " · ←→ choose"
|
||
case fkToggle:
|
||
keys += " · Space tick"
|
||
}
|
||
}
|
||
return keys + " · Esc done"
|
||
}
|