Once server init's screen closed, the whole run was printed again under the frame that had just shown it. Now the password and the next steps are, the whole run goes to /var/lib/llmgw/init.log (0600: it holds the password), and it is printed in full only when init fails. Seen in a draft of the install video, where it scrolled the screen away. The spinner and the text field are written here instead of taken from bubbles, whose packages query the terminal's colours as they load. bubbletea does the same deliberately, so the query stays; the dependency goes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
99 lines
2.6 KiB
Go
99 lines
2.6 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
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|