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>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 00:19:50 +02:00
1 parent 44425193bd
commit dac7ff0e99
7 files changed
+1129 -3

No files matched your search

+32
View File
@@ -53,6 +53,38 @@ The people side runs the same commands as `llmgw users`, `keys` and `org`,
against the same store, so their checks and messages are the ones shown. A
sidebar taller than the terminal now scrolls with the current entry.
### New weights in a model's repository, found and moved to
A repository on Hugging Face can publish new weights under the same file
names — a fixed chat template, a better quantisation — and nothing said so. A
model served from the registry now records its origin: the repository, the
commit, and each file's checksum there; a model served before is worked out
from the mirror's record of where it fetched each file. Every
`mirror.update_check` (six hours by default; 0 turns it off) the control
plane asks whether those files changed. A new commit that leaves them alone
is not an update.
- `llmgw ctl model updates [--check]` lists what each repository has.
- `llmgw ctl model update MODEL` fetches the new files into the mirror beside
the old ones, checks them against the registry's checksums and the nodes'
llama.cpp, points the catalog at them, replaces the replicas one at a
time and asks one to say hello.
- `llmgw ctl model rollback MODEL` goes back to the files the update
replaced, which stay on the mirror.
- The console shows UPDATE on Models and offers all three on a model's row.
A model from `server.yaml` is reported but not changed: its source is edited
there.
### The manual, in the binary
`llmgw docs` reads every document — installation, operation, the
configuration reference, the README, the changes — and a page for every
command, built from the commands' own help, like `info`: topics on the left,
T for a page's contents, / to find, n for the next match. Piped, it prints
the page. In the console it is **Help › Manual**, and **?** opens the new
page about the console itself from any screen.
## v0.11.1 — 2026-10-03
### The console's overview no longer spills over the screen
+10
View File
@@ -0,0 +1,10 @@
// Package llmgw holds what the binary carries besides code: its manual.
package llmgw
import "embed"
// Docs is the documentation, read on any machine by `llmgw docs` and the
// console's manual without a checkout or the internet.
//
//go:embed README.md CHANGELOG.md docs/*.md
var Docs embed.FS
+200
View File
@@ -0,0 +1,200 @@
# The console
`sudo llmgw`, with nothing after it, on a machine llmgw runs on. On a machine
with nothing installed it opens the setup screens; on a control plane, the
console described here; on a node, the node's own console, which edits
`agent.yaml`. Everything on these screens can also be done by command — each
screen says which — so a script never needs them and a person never needs
to remember the commands.
The keyboard drives it; there is no mouse. **?** opens this page from
anywhere, and the **Manual** entry holds every other page, as `llmgw docs`
does without the console.
## The layout
A bar across the top names the machine. Down the left, the entries, in four
groups: **Watch**, **People**, **Help**, **Settings** and **Do**. The panel on
the right shows the entry chosen. The bottom line always says which keys do
what at that moment, and a question or a result opens as a dialog over the
panel.
| Key | Does |
|---|---|
| ↑ ↓ | Choose an entry, or a row in the panel |
| → or Enter | Go into the panel |
| Enter on a row | What can be done to it: its details first, then the rest |
| ← or Esc | Back out: from a page to its list, from the panel to the entries |
| A | Add one, on a view that holds things: a quota, an account, a key |
| PgUp PgDn | Scroll a long panel |
| ? | This page |
| Q | Leave; the cluster keeps running |
| Ctrl-C | Leave at once |
What is watched is read again every three seconds.
## Forms and questions
A change that needs values opens a form, filled in with what is set now. Tab
or ↓ moves to the next field, ← → changes a choice, and Enter on **Apply**
sends it; a form of one field is sent by Enter on it. A mistake the form can
see — a maximum below the minimum, a word where a number goes — is said in the
form and nothing is sent. One the control plane refuses comes back as a dialog
with its reason, word for word.
Anything that cannot be undone — removing, draining, deleting, revoking —
asks first, and only **Y** goes ahead.
Tasks that take a while and print as they go — serving a model, upgrading
the cluster, a backup, adding a node — close the screen and run in the
terminal, so their output is there to read; Enter brings the console back.
## Watch
**Overview** is `llmgw ctl status`: nodes, GPUs by class, fabrics, replicas,
models, the store's members, and anything parked, unplaced or idle.
**Nodes** lists every node with its state, load, GPUs, replicas, disk and
agent version. On a row:
- **Details** — its address, system, load, engines and GPUs, and any warning.
- **Cordon** places nothing new on it; **Uncordon** undoes it.
- **Drain** moves its replicas off one at a time, each replacement up before
the original stops.
- **vllm build / sglang build** (nodes with GPUs) — which build of the engine
the node installs: `auto` follows its hardware, `none` installs none.
- **Upgrade its agent** to a release; its engines keep serving.
- **Delete model files nothing uses** frees the node's disk.
- **Remove from the cluster** — the control plane forgets it; the machine is
not touched.
**GPUs** is every card: how busy, its memory, temperature, power, and the
model holding it.
**Models** lists the catalog: ready against wanted, status, class, profiles,
and **UPDATE** when the registry has new weights for it. On a row:
- **Details** — description, replicas, class, owner, profiles, source, where
its weights came from, what it reserves against what it uses, and its
replicas.
- **Replicas (scale)** — the minimum kept running, the most it scales to,
and for a model loaded on demand how long it may sit unused. A maximum of
0 stops it.
- **Class** — critical, standard or opportunistic: whose capacity gives way
when there is not enough for everyone.
- **Owner** — the organization, team or project whose capacity quota it
counts against.
- **Resources** — the memory and cores one replica reserves on its node.
- **LoRA adapters** — `name=source[@scale]`, separated by commas.
- **Restart its replicas**, one by one or in place.
- **Update to …** — when the repository it came from has published different
weights for its files: they are fetched into the mirror beside the ones in
use, checked, swapped in, and the replicas replaced one at a time.
**Go back to …** returns to the weights the update replaced.
**Ask the registry for new weights now** checks without waiting for the
next scheduled look (`mirror.update_check`, six hours by default).
- **Why is it placed where it is** — `llmgw ctl explain-placement`.
- **Try again**, for a model parked after failing every start.
- **Remove** — its replicas stop and it leaves the catalog. A model from
`server.yaml` is changed there instead; the console says so.
**A** on Models adds one from a definition file on this machine: the YAML a
model takes in `server.yaml`.
**Replicas** lists every replica; on one, **Restart it**, replaced or in
place.
**Usage** is the last 24 hours by team, project or organization (→ changes
which): requests, tokens, errors and spend.
**Quotas** are capacity quotas: the GPUs, cores and memory each organization,
team or project is guaranteed and may at most hold, and what it holds now.
**A** adds one; on a row, change or remove it.
**Mirror** is what the model mirror holds, how far each download has got, and
which models use each file. **A** downloads a URL into it; on a row, where
nodes fetch it from, or remove it — with a warning when a model still uses it.
**Logs** shows a node's agent log, newest at the bottom, errors and warnings
coloured.
## People
The accounts, keys and organizations live in the store; these views read it
and change it with the same commands as `llmgw users`, `llmgw keys` and
`llmgw org`, so their checks and messages are the ones shown.
**Accounts** — who has signed up, their state, whether they see the cluster
dashboard, and what they belong to. **A** creates an account, already
approved. On a row: approve one waiting, suspend or reinstate, give or take
the cluster dashboard, add to or take out of an organization, team or
project, their limits, a new password (their sessions close), delete.
**API keys** — every key, its project, owner, state and last use. **A**
issues one; the key is shown once, in the result, and never again. On a row:
its limits, or revoke it.
**Organizations** — the tenancy tree: organizations, their teams, the teams'
projects, with members, keys and limits. **A** adds an organization. On a
row: add a team or project inside it, rename, move, limits for the level and
for each of its members, its capacity quota, and for a project how long
conversations and agent notes are kept. **Remove** takes away only something
empty.
Limits are requests and tokens a minute, a budget per day or month, and the
models allowed. Each level only narrows what is above it.
## Help
**Manual** lists every page there is: these, the installation and operation
guides, the configuration reference, a page for every command, the README
and the changes in each release. On a page:
| Key | Does |
|---|---|
| ↑ ↓, PgUp PgDn, Space | Scroll |
| g, G | The top, the end |
| T | Contents: the page's headings, Enter goes to one |
| / | Find; the lines holding it are marked |
| n, N | The next match, the one before |
| [ ] | The topic before, the one after |
| ← | Back to the list |
`llmgw docs` opens the same manual outside the console, on any machine,
installed or not; `llmgw docs install` opens one page; piped, it prints the
page's text.
## Settings
**server.yaml** (on a node, **agent.yaml**) is every key of the file, as a
tree of menus built from the configuration reference: the value set, or the
one that applies when nothing is, its type, and what it is for. Enter changes
a value or opens a block; on a list or map of blocks, Enter opens an entry and
**D** removes one. **U** puts a key back to its default. **S** saves: the file
is checked the way the service reads it before anything is written, and its
comments are kept. The service reads it when it starts, so the console offers
to restart it. Another control plane keeps its own copy.
## Do
- **Add a node** — installs llmgw on a machine over ssh, checks it, gives it a
certificate and starts its agent (`sudo llmgw node add`).
- **Replicate the store** — moves the store onto two more machines under
Patroni (offered only while it is not replicated).
- **Serve a model** — Hugging Face's GGUF models: trending, most downloaded,
most liked or newest (**S** changes which), or **/** to search. Enter on
one lists its files with their sizes; the one chosen is fetched into the
mirror, checked, added to the catalog and started, and a replica is asked
to say hello (`llmgw ctl model serve`). `mirror.registry` points it at
another registry, for a site without the internet.
- **Upgrade the cluster** to a release: this machine, then every node in turn
(`sudo llmgw ctl upgrade`).
- **Restart all replicas**, one at a time.
- **Back up now** — the store and `/etc/llmgw` into one archive
(`sudo llmgw server backup`).
- **Leave**.
## The node's console
On a node, `sudo llmgw` opens `agent.yaml` in the same settings view, and the
manual. The cluster is watched and changed from a control plane.
+1
View File
@@ -58,6 +58,7 @@ func Root() *cobra.Command {
newSelfUpgradeCmd(),
newReleaseCmd(),
newJoinCmd(),
newDocsCmd(),
)
return root
}
+71
View File
@@ -319,3 +319,74 @@ func TestAModelIsFoundOnTheRegistryAndServedFromTheConsole(t *testing.T) {
t.Fatalf("would run %q %q", act.kind, got)
}
}
func TestTheManualHasEveryPageAndFindsInThem(t *testing.T) {
v := newDocViewer()
for _, k := range []string{"console", "install", "reference", "commands", "changes"} {
if findTopic(v.topics, k) < 0 {
t.Errorf("no %s page", k)
}
}
cmds := commandsManual()
for _, c := range []string{"llmgw ctl model update", "llmgw users approve", "llmgw docs"} {
if !strings.Contains(cmds, "# "+c) {
t.Errorf("the commands page lacks %s", c)
}
}
// Every line fits the width it was laid out for.
for _, tp := range v.topics {
for _, l := range renderMarkdown(tp.src(), 80) {
if !strings.HasPrefix(l.plain, " ") && len([]rune(l.plain)) > 80 {
t.Errorf("%s: a line of %d: %q", tp.key, len([]rune(l.plain)), l.plain)
break
}
}
}
v.show(findTopic(v.topics, "console"))
v.query = "drain"
v.find(80, 0, 1)
if l := v.lines(80)[v.scroll+2]; !strings.Contains(strings.ToLower(l.plain), "drain") {
t.Errorf("found at %q", l.plain)
}
v.openTOC(80)
if v.toc == nil || len(v.toc.items) < 5 {
t.Fatal("no contents")
}
}
func TestQuestionMarkOpensTheConsolesPage(t *testing.T) {
m, _ := consoleWith(t, cNodes)
tm := press(m, "?")
cm := tm.(consoleModel)
if cm.menu != cManual || !cm.detail || cm.docs.topics[cm.docs.open].key != "console" {
t.Fatalf("menu %d detail %v", cm.menu, cm.detail)
}
if !strings.Contains(tm.View(), "The console") {
t.Error("the page is not shown")
}
}
func TestNextFindsTheNextMatchAndComesRound(t *testing.T) {
v := newDocViewer()
v.show(findTopic(v.topics, "console"))
v.query = "revok"
lines := v.lines(80)
var hits []int
for i, l := range lines {
if strings.Contains(strings.ToLower(l.plain), "revok") {
hits = append(hits, i)
}
}
if len(hits) < 2 {
t.Fatalf("hits %v", hits)
}
v.find(80, 0, 1)
seen := []int{v.scroll + 2}
for range hits {
v.key(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}, 80, 30)
seen = append(seen, v.scroll+2)
}
if seen[0] != hits[0] || seen[1] != hits[1] || seen[len(hits)] != hits[0] {
t.Errorf("hits %v, went %v", hits, seen)
}
}
+51 -3
View File
@@ -43,6 +43,7 @@ const (
cAccounts
cKeys
cOrgs
cManual
cSettings
cAddNode
cReplicate
@@ -54,7 +55,7 @@ const (
cCount
)
var consoleLabels = [cCount]string{"Overview", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Mirror", "Logs", "Accounts", "API keys", "Organizations",
var consoleLabels = [cCount]string{"Overview", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Mirror", "Logs", "Accounts", "API keys", "Organizations", "Manual",
"server.yaml", "Add a node", "Replicate the store", "Serve a model", "Upgrade the cluster",
"Restart all replicas", "Back up now", "Leave"}
@@ -154,13 +155,14 @@ type consoleModel struct {
people peopleData
mirror consoleMirrorMsg
hf hfState
docs docViewer
}
var usageGroupings = []string{"team", "project", "org"}
func newConsoleModel(addr, file string) consoleModel {
m := consoleModel{client: newCtlClient(addr), spin: newSpinner(), width: 100, height: 30, file: file,
st: &storeHolder{file: file}}
st: &storeHolder{file: file}, docs: newDocViewer()}
m.node = strings.HasSuffix(file, "agent.yaml")
if m.node {
m.menu = cSettings
@@ -329,7 +331,7 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m consoleModel) entryOK(i int) bool {
if m.node {
return i == cSettings || i == cLeave
return i == cSettings || i == cManual || i == cLeave
}
return i != cReplicate || !m.storeReplicated()
}
@@ -346,6 +348,26 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if mm, cmd, ok := m.actionKey(k); ok {
return mm, cmd
}
// The manual, from anywhere: its page about this screen.
if k.String() == "?" && !(m.menu == cManual && m.detail) {
m.menu, m.inPanel, m.detail = cManual, true, true
m.docs.show(max(0, findTopic(m.docs.topics, "console")))
return m, nil
}
if m.menu == cManual && m.inPanel && m.detail {
pw := max(40, m.width-navWidth-8)
if m.docs.key(k, pw-4, m.height) {
return m, nil
}
switch k.String() {
case "esc", "left", "h", "backspace":
m.detail = false
m.row = m.docs.open
case "q":
return m.leave()
}
return m, nil
}
switch k.Type {
case tea.KeyPgDown:
m.scroll += max(1, m.height-8)
@@ -434,6 +456,9 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.menuD = m.keyMenu(m.people.keys[m.row])
case cOrgs:
m.menuD = m.orgMenu(m.people.orgs[m.row])
case cManual:
m.docs.show(m.row)
m.detail = true
}
case "/":
if m.menu == cServe {
@@ -808,6 +833,8 @@ func (m consoleModel) rowCount() int {
return len(m.people.keys)
case cOrgs:
return len(m.people.orgs)
case cManual:
return len(m.docs.topics)
}
return 0
}
@@ -825,6 +852,8 @@ func (m consoleModel) View() string {
switch i {
case cAccounts:
nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "People", Heading: true})
case cManual:
nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "Help", Heading: true})
case cSettings:
nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "Settings", Heading: true})
case cAddNode, cLeave:
@@ -850,6 +879,9 @@ func (m consoleModel) View() string {
Width: m.width, Height: m.height, Title: title0, Where: host,
NavTitle: "", Nav: nav, PanelTitle: title, Body: body, Scroll: m.scroll, Keys: keys,
}
if m.menu == cManual && m.detail {
f.Scroll = m.docs.scroll
}
// The selected row kept on screen.
if m.inPanel && !m.detail {
inner := m.height - 4
@@ -869,6 +901,8 @@ func (m consoleModel) View() string {
}
f.Dialog = q + "\n\n" + m.prompt.View() + "\n\n" + sDim.Render("Enter · Esc cancel")
}
} else if t, d := m.docs.dialog(); d != "" && m.menu == cManual {
f.DialogTitle, f.Dialog = t, d
} else if m.busy != "" {
f.DialogTitle, f.Dialog = m.busy, m.spin.View()+" asking the control plane…"
} else if t, d := m.actionDialog(); d != "" {
@@ -1004,6 +1038,20 @@ func (m consoleModel) panel() (title, body, keys string) {
return m.quotasPanel(pw)
case cAccounts, cKeys, cOrgs:
return m.peoplePanel(pw)
case cManual:
if m.detail && m.docs.open >= 0 {
k := docKeys
if m.docs.note != "" {
k = m.docs.note
}
return "Manual · " + m.docs.topics[m.docs.open].title, "\n" + m.docs.body(pw-4), k
}
d := m.docs
d.sel = m.row
if m.inPanel {
keys = "↑↓ topic · Enter read · ← back · ? the console's page · Q leave"
}
return "Manual", d.list(pw, m.inPanel), keys
case cMirror:
return m.mirrorPanel(pw)
case cServe:
+764
View File
@@ -0,0 +1,764 @@
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
},
}
}