package cli import ( "context" "fmt" "io" "os" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-isatty" "gitea.mynodes.xyz/ilker/llmgw/internal/onboard" ) // The full-screen way through 'llmgw node add': the same steps as the plain // one, drawn as a checklist that fills in, with the questions a script // cannot ask — which fabric the adapters are on, whether to go on past a // problem — asked in place. func isTerminal(w io.Writer) bool { f, ok := w.(*os.File) return ok && isatty.IsTerminal(f.Fd()) && os.Getenv("TERM") != "dumb" } var ( tuiAccent = lipgloss.AdaptiveColor{Light: "#0B7F7D", Dark: "#3FC2BC"} tuiOK = lipgloss.AdaptiveColor{Light: "#1F8A4C", Dark: "#6FD3A6"} tuiWarn = lipgloss.AdaptiveColor{Light: "#A15C00", Dark: "#E9B35A"} tuiBad = lipgloss.AdaptiveColor{Light: "#B42318", Dark: "#F07C6C"} tuiDim = lipgloss.AdaptiveColor{Light: "#56696C", Dark: "#8FA5A4"} sTitle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent) sStep = lipgloss.NewStyle().Width(14) sDim = lipgloss.NewStyle().Foreground(tuiDim) sOK = lipgloss.NewStyle().Foreground(tuiOK) sWarn = lipgloss.NewStyle().Foreground(tuiWarn) sBad = lipgloss.NewStyle().Foreground(tuiBad) sBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiAccent).Padding(0, 1) ) // Messages from the onboarding goroutine to the screen. type ( evMsg onboard.Event finishMsg struct { node, summary string err error } askFabricsMsg struct { suggested []onboard.Fabric reply chan []onboard.Fabric } askContinueMsg struct { fails int reply chan bool } ) type stepState struct { level, detail string } type nodeModel struct { title string steps map[string]stepState notes []onboard.Event // warnings and failures, with their fixes spin spinnerModel cancel context.CancelFunc width int height int done bool err error node string summary string // The question being asked, if any. fabrics *askFabricsMsg inputs []textField focus int confirm *askContinueMsg quitAsked bool } func newNodeModel(title string, cancel context.CancelFunc) nodeModel { sp := newSpinner() return nodeModel{title: title, steps: map[string]stepState{}, spin: sp, cancel: cancel, width: 100} } func (m nodeModel) Init() tea.Cmd { return m.spin.Tick } func (m nodeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case spinTickMsg: var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd case evMsg: e := onboard.Event(msg) switch e.Level { case onboard.Running, onboard.Done: m.steps[e.Step] = stepState{e.Level, e.Detail} case onboard.Fail: m.steps[e.Step] = stepState{e.Level, e.Detail} if e.Step == onboard.StepCheck && strings.Contains(e.Detail, ":") { m.notes = append(m.notes, e) } case onboard.Warn: m.notes = append(m.notes, e) case onboard.Info: if e.Step == onboard.StepEngines { m.steps[e.Step] = stepState{onboard.Running, e.Detail} } } case askFabricsMsg: m.fabrics = &msg m.inputs = nil for i, f := range msg.suggested { in := newTextField() in.Prompt = "" in.SetValue(f.Name) in.CharLimit = 40 in.Width = 24 if i == 0 { in.Focus() } m.inputs = append(m.inputs, in) } m.focus = 0 case askContinueMsg: m.confirm = &msg case finishMsg: // Left on the screen until a key: the frame is gone once it closes. m.done, m.err, m.node, m.summary = true, msg.err, msg.node, msg.summary return m, nil case tea.KeyMsg: if m.done && (msg.Type == tea.KeyEnter || msg.String() == "q" || msg.Type == tea.KeyEsc) { return m, tea.Quit } return m.key(msg) } return m, nil } func (m nodeModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) { if k.Type == tea.KeyCtrlC { m.cancel() m.quitAsked = true return m, tea.Quit } switch { case m.confirm != nil: switch strings.ToLower(k.String()) { case "y": m.confirm.reply <- true m.confirm = nil case "n", "enter", "esc": m.confirm.reply <- false m.confirm = nil } return m, nil case m.fabrics != nil: switch k.Type { case tea.KeyTab, tea.KeyDown: m.move(1) return m, nil case tea.KeyShiftTab, tea.KeyUp: m.move(-1) return m, nil case tea.KeyEsc: m.fabrics.reply <- nil m.fabrics = nil return m, nil case tea.KeyEnter: var out []onboard.Fabric for i, in := range m.inputs { if name := strings.TrimSpace(in.Value()); name != "" { out = append(out, onboard.Fabric{Name: name, HCAs: m.fabrics.suggested[i].HCAs, Key: m.fabrics.suggested[i].Key}) } } m.fabrics.reply <- out m.fabrics = nil return m, nil } var cmd tea.Cmd m.inputs[m.focus], cmd = m.inputs[m.focus].Update(k) return m, cmd } if k.String() == "q" { m.cancel() m.quitAsked = true return m, tea.Quit } return m, nil } func (m *nodeModel) move(d int) { m.inputs[m.focus].Blur() m.focus = (m.focus + d + len(m.inputs)) % len(m.inputs) m.inputs[m.focus].Focus() } // View draws the onboarding in the frame: its steps down the side, what each // did in the panel, a question as a dialog. func (m nodeModel) View() string { var nav []navItem current := false for _, step := range onboard.Steps { st, ok := m.steps[step] state := "todo" switch { case ok && st.level == onboard.Running && !m.done && !current: state, current = "current", true case ok && st.level != onboard.Running: state = "done" } nav = append(nav, navItem{Label: step, State: state}) } keys := "Ctrl+C stop" if m.done { keys = "Enter close" } host, _ := os.Hostname() f := frame{ Width: m.width, Height: m.height, Title: "llmgw · add a node", Where: host, NavTitle: "Steps", Nav: nav, PanelTitle: m.title, Body: "\n" + m.content(), Keys: keys, } switch { case m.fabrics != nil: var q strings.Builder q.WriteString("These adapters are up. Which fabric is each group on?\n") q.WriteString(sDim.Render("Give each the name it has on every other node attached to it.\nLeave a name empty to leave that group out.") + "\n\n") for i, fb := range m.fabrics.suggested { fmt.Fprintf(&q, " %s %s\n", m.inputs[i].View(), sDim.Render(strings.Join(fb.HCAs, ", "))) } q.WriteString("\n" + sDim.Render("Tab next · Enter use these · Esc none")) f.DialogTitle, f.Dialog = "Fabrics", q.String() case m.confirm != nil: f.DialogTitle = "Problems found" f.Dialog = fmt.Sprintf("doctor found %d problem(s) on the machine.\nAdd the node anyway?\n\n%s", m.confirm.fails, sDim.Render("Y yes · N no")) } return f.render() } // content is the onboarding's steps and notes, as the panel shows them. func (m nodeModel) content() string { var b strings.Builder detailWidth := max(20, m.width-navWidth-26) for _, step := range onboard.Steps { st, ok := m.steps[step] var mark string switch { case !ok: mark = sDim.Render("·") case st.level == onboard.Running && !m.done: mark = m.spin.View() case st.level == onboard.Fail: mark = sBad.Render("✗") case st.level == onboard.Running: mark = sDim.Render("·") default: mark = sOK.Render("✓") } name := sStep.Render(step) if !ok { name = sDim.Render(sStep.Render(step)) } fmt.Fprintf(&b, " %s %s %s\n", mark, name, truncate(st.detail, detailWidth)) } if len(m.notes) > 0 { b.WriteString("\n") for _, n := range m.notes { mark := sWarn.Render("!") if n.Level == onboard.Fail { mark = sBad.Render("✗") } fmt.Fprintf(&b, " %s %s\n", mark, wrap(n.Detail, m.width-6, " ")) if n.Fix != "" { fmt.Fprintf(&b, " %s\n", sDim.Render("→ "+wrap(n.Fix, m.width-8, " "))) } } } switch { case m.done && m.err != nil: b.WriteString("\n" + sBad.Render("✗ ") + wrap(m.err.Error(), m.width-4, " ") + "\n") case m.done: b.WriteString("\n" + sOK.Render("✓ ") + m.summary + "\n") case m.quitAsked: b.WriteString("\n" + sDim.Render("stopped") + "\n") } return b.String() } func truncate(s string, n int) string { if n <= 1 || len([]rune(s)) <= n { return s } r := []rune(s) return string(r[:n-1]) + "…" } // wrap breaks s at spaces to fit width, indenting every line after the first. func wrap(s string, width int, indent string) string { if width < 20 { return s } var lines []string line := "" for _, w := range strings.Fields(s) { if line != "" && len(line)+1+len(w) > width { lines = append(lines, line) line = w continue } if line == "" { line = w } else { line += " " + w } } lines = append(lines, line) return strings.Join(lines, "\n"+indent) } // runTUI adds one node with the screen above. func (na *nodeAdd) runTUI(ctx context.Context, o onboard.Options, title string) (string, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() m := newNodeModel(title, cancel) p := tea.NewProgram(m, tea.WithAltScreen()) emit := func(e onboard.Event) { p.Send(evMsg(e)) } o.AskFabrics = na.rememberFabrics(func(s []onboard.Fabric) []onboard.Fabric { reply := make(chan []onboard.Fabric) p.Send(askFabricsMsg{s, reply}) select { case f := <-reply: return f case <-ctx.Done(): return nil } }) o.AskContinue = func(fails int) bool { reply := make(chan bool) p.Send(askContinueMsg{fails, reply}) select { case ok := <-reply: return ok case <-ctx.Done(): return false } } go func() { res, err := onboard.New(o, emit).Run(ctx) node := "" if res != nil { node = res.Node } if err == nil && !na.noWait { err = na.watch(ctx, node, emit) } summary := "" if err == nil { summary = node + " is in the cluster" } p.Send(finishMsg{node, summary, err}) }() final, err := p.Run() if err != nil { return "", err } fm := final.(nodeModel) // The screen is gone; what it ended with is said again where it stays. fmt.Println(fm.content()) if fm.quitAsked && !fm.done { cancel() return fm.node, fmt.Errorf("stopped; the node may be half set up, and running this again finishes it") } return fm.node, fm.err }