package cli import ( "fmt" "io/fs" "sort" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" llmgw "gitea.mynodes.xyz/ilker/llmgw" ) // The manual: every document the project has, carried in the binary, and a // page for every command built from the commands themselves — read like man // or info, in the console or with `llmgw docs`, on a machine with no checkout // and no browser. type docTopic struct { key string // what `llmgw docs KEY` takes title string src func() string } // docOrder is the documents in the order someone new reads them. var docOrder = []string{"console", "quickstart", "quick-install", "install", "node-onboarding", "onboarding", "choosing-models", "fabrics", "facility-aware-placement", "data", "reference"} func docTopics() []docTopic { read := func(path string) func() string { return func() string { b, err := fs.ReadFile(llmgw.Docs, path) if err != nil { return "# " + path + "\n\nNot in this build." } return string(b) } } titleOf := func(path string) string { b, _ := fs.ReadFile(llmgw.Docs, path) for _, l := range strings.Split(string(b), "\n") { if strings.HasPrefix(l, "# ") { return strings.TrimPrefix(l, "# ") } } return path } var out []docTopic seen := map[string]bool{} add := func(key, path string) { if seen[key] { return } if _, err := fs.Stat(llmgw.Docs, path); err != nil { return } seen[key] = true out = append(out, docTopic{key: key, title: titleOf(path), src: read(path)}) } for _, k := range docOrder { add(k, "docs/"+k+".md") if k == "reference" { out = append(out, docTopic{key: "commands", title: "Commands", src: commandsManual}) seen["commands"] = true } } // Any document not placed above, so a new one is never missing. entries, _ := fs.ReadDir(llmgw.Docs, "docs") var rest []string for _, e := range entries { rest = append(rest, strings.TrimSuffix(e.Name(), ".md")) } sort.Strings(rest) for _, k := range rest { add(k, "docs/"+k+".md") } add("readme", "README.md") add("changes", "CHANGELOG.md") return out } func findTopic(topics []docTopic, key string) int { key = strings.ToLower(strings.TrimSuffix(key, ".md")) for i, t := range topics { if t.key == key { return i } } for i, t := range topics { if strings.Contains(strings.ToLower(t.title), key) || strings.HasPrefix(t.key, key) { return i } } return -1 } // commandsManual is a page for every command, from its own help. func commandsManual() string { var b strings.Builder b.WriteString("# Commands\n\nEvery command, as `llmgw COMMAND --help` describes it. " + "Run bare, in a terminal, `llmgw` opens the setup screens or the console instead.\n") var walk func(c *cobra.Command) walk = func(c *cobra.Command) { if c.Hidden || c.Name() == "help" || c.Name() == "completion" { return } if c.HasParent() { level := min(strings.Count(c.CommandPath(), " ")+1, 4) fmt.Fprintf(&b, "\n%s %s\n\n", strings.Repeat("#", level), c.CommandPath()) if c.Long != "" { b.WriteString(c.Long + "\n") } else if c.Short != "" { b.WriteString(c.Short + ".\n") } b.WriteString("\n```\n" + c.UseLine() + "\n") if c.Example != "" { b.WriteString("\n" + strings.TrimRight(c.Example, "\n") + "\n") } if fl := c.LocalFlags(); fl.HasAvailableFlags() { b.WriteString("\n" + strings.TrimRight(fl.FlagUsagesWrapped(100), "\n") + "\n") } b.WriteString("```\n") } subs := c.Commands() sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() }) for _, s := range subs { walk(s) } } walk(Root()) return b.String() } // --- markdown, for a terminal --- var ( mdH1 = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent).Underline(true) mdH2 = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent) mdH3 = lipgloss.NewStyle().Bold(true) mdCode = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#8A4B08", Dark: "#E5C07B"}) mdBold = lipgloss.NewStyle().Bold(true) mdHit = lipgloss.NewStyle().Reverse(true) mdEm = lipgloss.NewStyle().Italic(true) mdLink = lipgloss.NewStyle().Underline(true) ) // docLine is one line of a rendered document: as shown, as plain text for // searching, and its heading level if it is one. type docLine struct { text, plain string heading int } type mdRun struct { text string style *lipgloss.Style link string } // inline splits markdown's inline markup into styled runs. func inline(s string) []mdRun { var out []mdRun plain := func(t string) { if t != "" { out = append(out, mdRun{text: t}) } } for s != "" { i := strings.IndexAny(s, "`*[") if i < 0 { plain(s) break } plain(s[:i]) s = s[i:] switch { case s[0] == '`': if j := strings.IndexByte(s[1:], '`'); j >= 0 { out = append(out, mdRun{text: s[1 : j+1], style: &mdCode}) s = s[j+2:] continue } case strings.HasPrefix(s, "**"): if j := strings.Index(s[2:], "**"); j >= 0 { out = append(out, mdRun{text: s[2 : j+2], style: &mdBold}) s = s[j+4:] continue } case s[0] == '*': // *emphasis*, when it closes and is not a lone star in text. if j := strings.IndexByte(s[1:], '*'); j > 0 && s[1] != ' ' { out = append(out, mdRun{text: s[1 : j+1], style: &mdEm}) s = s[j+2:] continue } case s[0] == '[': if j := strings.Index(s, "]("); j > 0 { if k := strings.IndexByte(s[j:], ')'); k > 0 { out = append(out, mdRun{text: s[1:j], link: s[j+2 : j+k]}) s = s[j+k+1:] continue } } } plain(s[:1]) s = s[1:] } return out } // wrapRuns lays styled runs out in lines of at most width columns: words // are cut at spaces, a word keeping each part's own style. func wrapRuns(runs []mdRun, width int, first, rest string) []docLine { type word struct{ plain, shown string } var words []word var plain, shown strings.Builder end := func() { if plain.Len() > 0 { words = append(words, word{plain.String(), shown.String()}) } plain.Reset() shown.Reset() } for _, r := range runs { text := r.text if r.link != "" && strings.HasPrefix(r.link, "http") { text += " ‹" + r.link + "›" } for k, part := range strings.Split(text, " ") { if k > 0 { end() } if part == "" { continue } plain.WriteString(part) switch { case r.style != nil: shown.WriteString(r.style.Render(part)) case r.link != "": shown.WriteString(mdLink.Render(part)) default: shown.WriteString(part) } } } end() var out []docLine var line, lp strings.Builder start := func(pre string) { line.Reset() lp.Reset() line.WriteString(pre) lp.WriteString(pre) } start(first) col, empty := len([]rune(first)), true for _, w := range words { n := len([]rune(w.plain)) if !empty && col+1+n > width { out = append(out, docLine{text: line.String(), plain: lp.String()}) start(rest) col, empty = len([]rune(rest)), true } if !empty { line.WriteString(" ") lp.WriteString(" ") col++ } line.WriteString(w.shown) lp.WriteString(w.plain) col += n empty = false } if !empty { out = append(out, docLine{text: line.String(), plain: lp.String()}) } return out } // renderMarkdown lays a document out for a terminal width. func renderMarkdown(src string, width int) []docLine { width = max(width, 30) var out []docLine blank := func() { if len(out) > 0 && out[len(out)-1].plain != "" { out = append(out, docLine{}) } } lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") var para []string flush := func() { if len(para) > 0 { out = append(out, wrapRuns(inline(strings.Join(para, " ")), width, "", "")...) para = nil } } for i := 0; i < len(lines); i++ { l := lines[i] t := strings.TrimSpace(l) switch { case strings.HasPrefix(t, "```"): flush() blank() for i++; i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```"); i++ { code := " " + strings.ReplaceAll(lines[i], "\t", " ") out = append(out, docLine{text: mdCode.Render(truncateWidth(code, width)), plain: code}) } blank() case t == "": flush() blank() case strings.HasPrefix(t, "#"): flush() level := len(t) - len(strings.TrimLeft(t, "#")) title := strings.TrimSpace(t[level:]) plainTitle := "" for _, r := range inline(title) { plainTitle += r.text } style := mdH3 switch level { case 1: style = mdH1 case 2: style = mdH2 } blank() out = append(out, docLine{text: style.Render(plainTitle), plain: plainTitle, heading: level}) out = append(out, docLine{}) case strings.HasPrefix(t, "|"): flush() var rows [][]string for ; i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|"); i++ { cells := strings.Split(strings.Trim(strings.TrimSpace(lines[i]), "|"), "|") sep := true for j := range cells { cells[j] = strings.TrimSpace(cells[j]) if strings.Trim(cells[j], ":-") != "" { sep = false } } if !sep { rows = append(rows, cells) } } i-- out = append(out, renderTable(rows, width)...) case strings.HasPrefix(t, ">"): flush() q := strings.TrimSpace(strings.TrimPrefix(t, ">")) for _, d := range wrapRuns(inline(q), width-2, "", "") { out = append(out, docLine{text: sDim.Render("│ ") + d.text, plain: "│ " + d.plain}) } case listItem(t) != "": flush() marker := listItem(t) indent := strings.Repeat(" ", len(l)-len(strings.TrimLeft(l, " "))) body := strings.TrimSpace(t[len(marker):]) // The item's following lines, indented under it. for i+1 < len(lines) { nt := strings.TrimSpace(lines[i+1]) if nt == "" || listItem(nt) != "" || strings.HasPrefix(nt, "```") || strings.HasPrefix(nt, "#") || !strings.HasPrefix(lines[i+1], " ") { break } body += " " + nt i++ } bullet := "• " if marker != "- " && marker != "* " { bullet = marker } out = append(out, wrapRuns(inline(body), width, indent+bullet, indent+strings.Repeat(" ", len([]rune(bullet))))...) default: para = append(para, t) } } flush() return out } func listItem(t string) string { if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") { return t[:2] } for i, c := range t { if c >= '0' && c <= '9' { continue } if i > 0 && i < 4 && c == '.' && strings.HasPrefix(t[i+1:], " ") { return t[:i+2] } break } return "" } // renderTable aligns a markdown table, wrapping the widest column to fit. func renderTable(rows [][]string, width int) []docLine { if len(rows) == 0 { return nil } plainCell := func(s string) string { var p string for _, r := range inline(s) { p += r.text } return p } n := 0 for _, r := range rows { n = max(n, len(r)) } w := make([]int, n) for _, r := range rows { for j, c := range r { w[j] = max(w[j], len([]rune(plainCell(c)))) } } total := func() int { t := 0 for _, x := range w { t += x + 2 } return t } for total() > width { widest := 0 for j := range w { if w[j] > w[widest] { widest = j } } if w[widest] <= 12 { break } w[widest]-- } var out []docLine for ri, r := range rows { cols := make([][]string, n) height := 1 for j := 0; j < n; j++ { c := "" if j < len(r) { c = plainCell(r[j]) } cols[j] = strings.Split(wrap(c, max(w[j], 20), ""), "\n") if len([]rune(c)) <= w[j] { cols[j] = []string{c} } height = max(height, len(cols[j])) } for h := 0; h < height; h++ { var b strings.Builder for j := 0; j < n; j++ { c := "" if h < len(cols[j]) { c = cols[j][h] } b.WriteString(fmt.Sprintf("%-*s ", w[j], truncate(c, w[j]))) } plain := strings.TrimRight(b.String(), " ") text := plain if ri == 0 { text = mdBold.Render(plain) } out = append(out, docLine{text: text, plain: plain}) } } return out } // --- the viewer --- // docViewer is the manual: a list of topics, one open, its contents, and a // search. Both the console and `llmgw docs` show it. type docViewer struct { topics []docTopic open int // the topic shown; -1 is the list sel int // the topic chosen in the list scroll int query string // toc and search are dialogs over the page. toc *menuDialog tocLines []int asking bool prompt textField note string } func newDocViewer() docViewer { return docViewer{topics: docTopics(), open: -1} } func (v *docViewer) lines(width int) []docLine { if v.open < 0 { return nil } return renderMarkdown(v.topics[v.open].src(), width) } // key handles a key on an open page; false hands it back. func (v *docViewer) key(k tea.KeyMsg, width, height int) bool { if v.asking { switch k.Type { case tea.KeyEsc: v.asking = false case tea.KeyEnter: v.asking = false v.query = strings.TrimSpace(v.prompt.Value()) v.find(width, v.scroll, 1) default: v.prompt, _ = v.prompt.Update(k) } return true } if v.toc != nil { switch k.String() { case "up", "k": v.toc.sel = (v.toc.sel - 1 + len(v.toc.items)) % len(v.toc.items) case "down", "j", "tab": v.toc.sel = (v.toc.sel + 1) % len(v.toc.items) case "pgdown": v.toc.sel = min(len(v.toc.items)-1, v.toc.sel+10) case "pgup": v.toc.sel = max(0, v.toc.sel-10) case "enter": v.scroll = max(0, v.tocLines[v.toc.sel]-1) v.toc = nil case "esc", "q", "t": v.toc = nil } return true } if v.open < 0 { return false } n := len(v.lines(width)) page := max(1, height-6) switch k.String() { case "up", "k": v.scroll-- case "down", "j", "enter": v.scroll++ case "pgdown", " ", "f": v.scroll += page case "pgup", "b": v.scroll -= page case "home", "g": v.scroll = 0 case "end", "G": v.scroll = n - page case "t": v.openTOC(width) case "/": v.asking = true v.prompt = newTextField() v.prompt.Width = 30 v.prompt.SetValue(v.query) v.prompt.Focus() case "n": // A match is shown two lines down; the next is after it. v.find(width, v.scroll+3, 1) case "N": v.find(width, v.scroll+1, -1) case "]": v.show(min(len(v.topics)-1, v.open+1)) case "[": v.show(max(0, v.open-1)) default: return false } v.scroll = max(0, min(v.scroll, n-1)) return true } func (v *docViewer) show(i int) { v.open, v.sel, v.scroll, v.note = i, i, 0, "" } func (v *docViewer) openTOC(width int) { v.toc, v.tocLines = &menuDialog{title: "Contents · " + v.topics[v.open].title}, nil for i, l := range v.lines(width) { if l.heading == 0 || l.heading > 3 { continue } v.toc.items = append(v.toc.items, menuItem{label: strings.Repeat(" ", max(0, l.heading-1)) + truncate(l.plain, 60)}) v.tocLines = append(v.tocLines, i) if i <= v.scroll+1 { v.toc.sel = len(v.tocLines) - 1 } } if len(v.toc.items) == 0 { v.toc = nil } } // find moves to the next line holding the query, from line `from` in // direction dir, wrapping round. func (v *docViewer) find(width, from, dir int) { if v.query == "" { return } lines := v.lines(width) q := strings.ToLower(v.query) for k := 0; k < len(lines); k++ { i := ((from+k*dir)%len(lines) + len(lines)) % len(lines) if strings.Contains(strings.ToLower(lines[i].plain), q) { v.scroll = max(0, i-2) v.note = "" return } } v.note = "“" + v.query + "” is not in this page" } // body is the open page, the query's lines marked. func (v *docViewer) body(width int) string { var b strings.Builder q := strings.ToLower(v.query) for _, l := range v.lines(width) { text := l.text if q != "" && strings.Contains(strings.ToLower(l.plain), q) { text = mdHit.Render(" ") + " " + text } else { text = " " + text } b.WriteString(text + "\n") } return b.String() } // list is the topics, as a table. func (v *docViewer) list(width int, focused bool) string { var rows [][]string for _, t := range v.topics { rows = append(rows, []string{t.title, sDim.Render("llmgw docs " + t.key)}) } return "\n " + sDim.Render("Everything there is to read about llmgw, carried in the binary.") + "\n\n" + table([]string{"TOPIC", "ALSO"}, rows, v.sel, width, focused) } // dialog is the contents or the search, open over the page. func (v *docViewer) dialog() (string, string) { switch { case v.asking: return "Find in this page", v.prompt.View() + "\n\n" + sDim.Render("Enter · then n next, N back · Esc cancel") case v.toc != nil: d := consoleModel{menuD: v.toc, height: 40} _, body := d.actionDialog() return v.toc.title, body case v.note != "": return "", "" } return "", "" } const docKeys = "↑↓ PgUp/PgDn g/G scroll · T contents · / find · n/N next · [ ] topic · ← back" // --- `llmgw docs` --- type docsModel struct { v docViewer width, height int inPage bool } func (m docsModel) Init() tea.Cmd { return nil } func (m docsModel) pageWidth() int { return max(30, m.width-navWidth-10) } func (m docsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case tea.KeyMsg: if msg.Type == tea.KeyCtrlC { return m, tea.Quit } if m.inPage && m.v.key(msg, m.pageWidth(), m.height) { return m, nil } switch msg.String() { case "q": return m, tea.Quit case "up", "k": if !m.inPage { m.v.sel = (m.v.sel - 1 + len(m.v.topics)) % len(m.v.topics) m.v.show(m.v.sel) } case "down", "j": if !m.inPage { m.v.sel = (m.v.sel + 1) % len(m.v.topics) m.v.show(m.v.sel) } case "enter", "right", "l", "tab": m.inPage = true case "esc", "left", "h", "backspace": if m.inPage { m.inPage = false } else { return m, tea.Quit } } } return m, nil } func (m docsModel) View() string { var nav []navItem for i, t := range m.v.topics { nav = append(nav, navItem{Label: t.title, Current: i == m.v.open}) } keys := "↑↓ topic · → or Enter read · Q leave" if m.inPage { keys = docKeys } if m.v.note != "" { keys = m.v.note } f := frame{Width: m.width, Height: m.height, Title: "llmgw manual", Where: "llmgw docs", Nav: nav, PanelTitle: m.v.topics[m.v.open].title, Body: "\n" + m.v.body(m.pageWidth()), Scroll: m.v.scroll, Keys: keys} f.DialogTitle, f.Dialog = m.v.dialog() return f.render() } func newDocsCmd() *cobra.Command { return &cobra.Command{ Use: "docs [TOPIC]", Short: "Read the manual: every document, and a page for every command", Long: "In a terminal, a reader like info: the topics down the left, the page on\n" + "the right, T for its contents, / to find. Piped, the topic's text, or the\n" + "list of topics. TOPIC is a name from that list or a word of its title.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { v := newDocViewer() i := 0 if len(args) == 1 { if i = findTopic(v.topics, args[0]); i < 0 { return fmt.Errorf("no topic %q; `llmgw docs` lists them", args[0]) } } out := cmd.OutOrStdout() if !interactive(cmd) { if len(args) == 0 { for _, t := range v.topics { fmt.Fprintf(out, "%-26s %s\n", t.key, t.title) } return nil } _, err := fmt.Fprint(out, v.topics[i].src()) return err } v.show(i) m := docsModel{v: v, width: 100, height: 30, inPage: len(args) == 1} _, err := tea.NewProgram(m, tea.WithOutput(out), tea.WithAltScreen()).Run() return err }, } }