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>
765 lines
19 KiB
Go
765 lines
19 KiB
Go
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
|
||
},
|
||
}
|
||
}
|