diff --git a/CHANGELOG.md b/CHANGELOG.md index 2444ca7..5509bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..11e35bc --- /dev/null +++ b/docs.go @@ -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 diff --git a/docs/console.md b/docs/console.md new file mode 100644 index 0000000..676e27b --- /dev/null +++ b/docs/console.md @@ -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. diff --git a/internal/cli/root.go b/internal/cli/root.go index 09e3f44..e6f61fd 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -58,6 +58,7 @@ func Root() *cobra.Command { newSelfUpgradeCmd(), newReleaseCmd(), newJoinCmd(), + newDocsCmd(), ) return root } diff --git a/internal/cli/tui_actions_test.go b/internal/cli/tui_actions_test.go index 1e5da5a..75e777e 100644 --- a/internal/cli/tui_actions_test.go +++ b/internal/cli/tui_actions_test.go @@ -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) + } +} diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go index ce7e888..4d59198 100644 --- a/internal/cli/tui_console.go +++ b/internal/cli/tui_console.go @@ -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: diff --git a/internal/cli/tui_docs.go b/internal/cli/tui_docs.go new file mode 100644 index 0000000..ee689bf --- /dev/null +++ b/internal/cli/tui_docs.go @@ -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 + }, + } +}