The manual the binary carries, rendered on the client port: a sidebar of the documents in the console's reading order (now one list, llmgw.Manual, for both), heading anchors, links between documents kept in the portal and links to files outside the binary sent to the repository, no script. Public by default; landing.docs_sign_in sends visitors to sign in first. docs/clients.md shows the official OpenAI and Anthropic SDKs against llmgw in Python, TypeScript, Go and curl, with llmgw's headers; the portal's index, the landing page and the account page link to it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
721 lines
18 KiB
Go
721 lines
18 KiB
Go
package cli
|
||
|
||
import (
|
||
"fmt"
|
||
"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
|
||
}
|
||
|
||
// docTopics is the manual: the documents in llmgw.Manual's order, which the
|
||
// gateway's docs portal shares, with a page for the commands after the
|
||
// configuration reference — the one page only the command tree can write.
|
||
func docTopics() []docTopic {
|
||
var out []docTopic
|
||
for _, d := range llmgw.Manual() {
|
||
out = append(out, docTopic{key: d.Key, title: d.Title, src: d.Source})
|
||
if d.Key == "reference" {
|
||
out = append(out, docTopic{key: "commands", title: "Commands", src: commandsManual})
|
||
}
|
||
}
|
||
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
|
||
},
|
||
}
|
||
}
|