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:
1 parent
44425193bd
commit
dac7ff0e99
7 files changed
+1129
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -58,6 +58,7 @@ func Root() *cobra.Command {
|
||||
newSelfUpgradeCmd(),
|
||||
newReleaseCmd(),
|
||||
newJoinCmd(),
|
||||
newDocsCmd(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user