Files
llmgw/internal/cli/tui_docs.go
T
Ilker ManapandClaude Opus 5.5 dac7ff0e99 The manual in the binary: llmgw docs, and Help in the console
Every document and a page for every command, built from their help, read
like info: topics, contents, find. ? opens the console's own page, which is
new and covers every screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:19:50 +02:00

765 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
},
}
}