With nothing installed, sudo llmgw asks for one machine or three, takes how to reach the other two (ssh agent, key or password, and a sudo password used only for the install), checks every machine first, shows the plan and its command line, and installs. On a control plane it opens a console with the cluster's state and the next steps. Machines are reached with Go's ssh so a password works; the store's etcd and access rules use this machine's routed address rather than a client-facing name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
104 lines
2.7 KiB
Go
104 lines
2.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Two small widgets, written here rather than taken from bubbles: its
|
|
// packages ask the terminal for its colours as they load, so every llmgw
|
|
// command — llmgw --version included — sent a query and, in a terminal that
|
|
// never answers one, waited five seconds for it. Only the screens need to
|
|
// know the colours, and lipgloss asks when they first draw.
|
|
|
|
// spinTickMsg advances a spinner.
|
|
type spinTickMsg struct{}
|
|
|
|
var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
|
|
type spinnerModel struct {
|
|
frame int
|
|
Style lipgloss.Style
|
|
}
|
|
|
|
func newSpinner() spinnerModel { return spinnerModel{Style: lipgloss.NewStyle().Foreground(tuiAccent)} }
|
|
|
|
// Tick starts the spinner turning; it is a tea.Cmd.
|
|
func (s spinnerModel) Tick() tea.Msg { return spinTickMsg{} }
|
|
|
|
func (s spinnerModel) Update(msg tea.Msg) (spinnerModel, tea.Cmd) {
|
|
if _, ok := msg.(spinTickMsg); !ok {
|
|
return s, nil
|
|
}
|
|
s.frame = (s.frame + 1) % len(spinFrames)
|
|
return s, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return spinTickMsg{} })
|
|
}
|
|
|
|
func (s spinnerModel) View() string { return s.Style.Render(spinFrames[s.frame]) }
|
|
|
|
// textField is one line of text typed at its end: all a form here needs.
|
|
type textField struct {
|
|
value []rune
|
|
focused bool
|
|
Prompt string
|
|
Placeholder string
|
|
CharLimit int
|
|
Width int
|
|
// Mask shows a dot for every character: a password.
|
|
Mask bool
|
|
}
|
|
|
|
func newTextField() textField { return textField{Prompt: "> "} }
|
|
|
|
func (f *textField) SetValue(s string) { f.value = []rune(s) }
|
|
func (f textField) Value() string { return string(f.value) }
|
|
func (f *textField) Focus() { f.focused = true }
|
|
func (f *textField) Blur() { f.focused = false }
|
|
|
|
func (f textField) Update(msg tea.Msg) (textField, tea.Cmd) {
|
|
k, ok := msg.(tea.KeyMsg)
|
|
if !ok || !f.focused {
|
|
return f, nil
|
|
}
|
|
switch k.Type {
|
|
case tea.KeyRunes, tea.KeySpace:
|
|
r := k.Runes
|
|
if k.Type == tea.KeySpace {
|
|
r = []rune{' '}
|
|
}
|
|
for _, c := range r {
|
|
if f.CharLimit == 0 || len(f.value) < f.CharLimit {
|
|
f.value = append(f.value, c)
|
|
}
|
|
}
|
|
case tea.KeyBackspace:
|
|
if len(f.value) > 0 {
|
|
f.value = f.value[:len(f.value)-1]
|
|
}
|
|
case tea.KeyCtrlU:
|
|
f.value = nil
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func (f textField) View() string {
|
|
text := string(f.value)
|
|
if f.Mask {
|
|
text = strings.Repeat("•", len(f.value))
|
|
}
|
|
shown := text
|
|
if text == "" && f.Placeholder != "" {
|
|
shown = sDim.Render(f.Placeholder)
|
|
}
|
|
if f.focused {
|
|
shown += "█"
|
|
}
|
|
if pad := f.Width - len([]rune(text)); pad > 0 && text != "" {
|
|
shown += strings.Repeat(" ", pad)
|
|
}
|
|
return f.Prompt + shown
|
|
}
|