The console gains live views of nodes, GPUs, models, replicas, usage and agent logs, and a settings tree over every key of server.yaml (agent.yaml on a node), generated from the configuration structs: values with their defaults and doc comments, edited in dialogs, saved only once the service's own loader accepts them, comments kept. The generator now tells blocks apart by package, which brings the TLS, engine and supervisor settings back into the reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
238 lines
6.7 KiB
Go
238 lines
6.7 KiB
Go
package cli
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/charmbracelet/x/ansi"
|
||
)
|
||
|
||
// The frame every full-screen llmgw screen is drawn in: a title bar, a
|
||
// sidebar of steps or actions on the left, the content in a bordered panel on
|
||
// the right, the keys along the bottom — and a dialog over it all when one is
|
||
// open. One frame, so the setup and the console read as one program.
|
||
//
|
||
// Keyboard only: these run on serial consoles and IPMI as often as in a
|
||
// desktop terminal.
|
||
|
||
// navItem is one line of the sidebar.
|
||
type navItem struct {
|
||
Label string
|
||
// State is "done", "current", "todo" for steps; "" for menu entries,
|
||
// where Current marks the one focused.
|
||
State string
|
||
Current bool
|
||
// Dim greys an entry that cannot be chosen now.
|
||
Dim bool
|
||
// Heading is a title between groups of entries, not an entry.
|
||
Heading bool
|
||
}
|
||
|
||
type frame struct {
|
||
Width, Height int
|
||
Title string // left of the title bar
|
||
Where string // right of the title bar: the machine
|
||
NavTitle string
|
||
Nav []navItem
|
||
PanelTitle string
|
||
Body string
|
||
// Scroll is how many of the body's lines are scrolled off the top.
|
||
Scroll int
|
||
Keys string
|
||
// Dialog, when set, is drawn over the middle of the screen.
|
||
Dialog string
|
||
DialogTitle string
|
||
}
|
||
|
||
var (
|
||
fBar = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1B1B"}).Background(tuiAccent)
|
||
fBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiDim)
|
||
fFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiAccent)
|
||
fKeys = lipgloss.NewStyle().Foreground(tuiDim)
|
||
fSelected = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent)
|
||
fDialog = lipgloss.NewStyle().Border(lipgloss.DoubleBorder()).BorderForeground(tuiAccent).Padding(1, 2)
|
||
)
|
||
|
||
const navWidth = 24
|
||
|
||
// render draws the whole screen, exactly Width by Height.
|
||
func (f frame) render() string {
|
||
w, h := max(f.Width, 60), max(f.Height, 16)
|
||
|
||
title := " " + f.Title
|
||
right := f.Where + " "
|
||
gap := max(1, w-ansi.StringWidth(title)-ansi.StringWidth(right))
|
||
bar := fBar.Render(title + strings.Repeat(" ", gap) + right)
|
||
|
||
// Panels fill what the bars leave: one line each above and below.
|
||
ph := h - 2
|
||
inner := ph - 2 // borders
|
||
|
||
var nav strings.Builder
|
||
for _, it := range f.Nav {
|
||
mark := " "
|
||
label := it.Label
|
||
if it.Heading {
|
||
nav.WriteString(sDim.Render(label) + "\n")
|
||
continue
|
||
}
|
||
switch {
|
||
case it.State == "done":
|
||
mark = sOK.Render("✓ ")
|
||
case it.State == "current":
|
||
mark = fSelected.Render("› ")
|
||
label = fSelected.Render(label)
|
||
case it.Current:
|
||
mark = fSelected.Render("› ")
|
||
label = fSelected.Render(label)
|
||
case it.Dim || it.State == "todo":
|
||
label = sDim.Render(label)
|
||
}
|
||
nav.WriteString(truncateWidth(mark+label, navWidth-2) + "\n")
|
||
}
|
||
left := box(fBorder, f.NavTitle, nav.String(), navWidth, inner)
|
||
|
||
pw := w - navWidth - 2 - 2
|
||
lines := strings.Split(strings.TrimRight(f.Body, "\n"), "\n")
|
||
scroll := min(max(f.Scroll, 0), max(0, len(lines)-inner))
|
||
visible := lines[scroll:]
|
||
if len(visible) > inner {
|
||
visible = visible[:inner]
|
||
// Said in the last line, where it is seen.
|
||
visible[inner-1] = sDim.Render(" ↓ more — PgDn")
|
||
}
|
||
for i := range visible {
|
||
visible[i] = truncateWidth(visible[i], pw)
|
||
}
|
||
panelTitle := f.PanelTitle
|
||
if scroll > 0 {
|
||
panelTitle += " ↑"
|
||
}
|
||
main := box(fFocus, panelTitle, strings.Join(visible, "\n"), pw, inner)
|
||
|
||
keys := fKeys.Render(" " + truncateWidth(f.Keys, w-2))
|
||
screen := bar + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, main) + "\n" + keys
|
||
|
||
if f.Dialog != "" {
|
||
dw := min(w-8, max(50, maxLineWidth(f.Dialog)+6))
|
||
d := fDialog.Width(dw).Render(f.Dialog)
|
||
if f.DialogTitle != "" {
|
||
d = titled(d, f.DialogTitle)
|
||
}
|
||
screen = overlay(screen, d, w, h)
|
||
}
|
||
return screen
|
||
}
|
||
|
||
// box draws content in a border of the given inner size, with a title set
|
||
// into the top edge.
|
||
func box(style lipgloss.Style, title, content string, width, height int) string {
|
||
b := style.Width(width).Height(height).Render(content)
|
||
if title == "" {
|
||
return b
|
||
}
|
||
return titled(b, title)
|
||
}
|
||
|
||
// titled sets " title " into a box's top border, after its corner.
|
||
func titled(b, title string) string {
|
||
lines := strings.Split(b, "\n")
|
||
if len(lines) == 0 {
|
||
return b
|
||
}
|
||
top := lines[0]
|
||
t := " " + title + " "
|
||
tw := ansi.StringWidth(t)
|
||
if ansi.StringWidth(top) < tw+4 {
|
||
return b
|
||
}
|
||
lines[0] = ansi.Truncate(top, 2, "") + sTitle.Render(t) + ansi.TruncateLeft(top, 2+tw, "")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// overlay draws top over the middle of base, both w by h.
|
||
func overlay(base, top string, w, h int) string {
|
||
bl := strings.Split(base, "\n")
|
||
tl := strings.Split(top, "\n")
|
||
tw := maxLineWidth(top)
|
||
x := max(0, (w-tw)/2)
|
||
y := max(0, (h-len(tl))/2)
|
||
for i, l := range tl {
|
||
row := y + i
|
||
if row >= len(bl) {
|
||
break
|
||
}
|
||
under := bl[row]
|
||
left := ansi.Truncate(under, x, "")
|
||
if pad := x - ansi.StringWidth(left); pad > 0 {
|
||
left += strings.Repeat(" ", pad)
|
||
}
|
||
right := ansi.TruncateLeft(under, x+ansi.StringWidth(l), "")
|
||
bl[row] = left + l + right
|
||
}
|
||
return strings.Join(bl, "\n")
|
||
}
|
||
|
||
func maxLineWidth(s string) int {
|
||
n := 0
|
||
for _, l := range strings.Split(s, "\n") {
|
||
n = max(n, ansi.StringWidth(l))
|
||
}
|
||
return n
|
||
}
|
||
|
||
// truncateWidth cuts a line, colours and all, to w cells.
|
||
func truncateWidth(s string, w int) string {
|
||
if ansi.StringWidth(s) <= w {
|
||
return s
|
||
}
|
||
return ansi.Truncate(s, max(w-1, 0), "…")
|
||
}
|
||
|
||
// table draws rows under a header in columns as wide as they need, cut to
|
||
// width; the selected row is shown reversed when the panel has the focus.
|
||
func table(headers []string, rows [][]string, selected int, width int, focused bool) string {
|
||
widths := make([]int, len(headers))
|
||
for i, h := range headers {
|
||
widths[i] = ansi.StringWidth(h)
|
||
}
|
||
for _, r := range rows {
|
||
for i := range headers {
|
||
if i < len(r) {
|
||
widths[i] = min(max(widths[i], ansi.StringWidth(r[i])), 44)
|
||
}
|
||
}
|
||
}
|
||
line := func(cells []string) string {
|
||
var b strings.Builder
|
||
for i := range headers {
|
||
c := ""
|
||
if i < len(cells) {
|
||
c = truncateWidth(cells[i], widths[i])
|
||
}
|
||
b.WriteString(c)
|
||
if i < len(headers)-1 {
|
||
b.WriteString(strings.Repeat(" ", widths[i]-ansi.StringWidth(c)+2))
|
||
}
|
||
}
|
||
return truncateWidth(b.String(), width)
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(" " + lipgloss.NewStyle().Bold(true).Foreground(tuiDim).Render(line(headers)) + "\n")
|
||
for i, r := range rows {
|
||
l := line(r)
|
||
switch {
|
||
case i == selected && focused:
|
||
pad := max(0, width-ansi.StringWidth(ansi.Strip(l)))
|
||
l = fBar.Render(ansi.Strip(l) + strings.Repeat(" ", pad))
|
||
case i == selected:
|
||
l = lipgloss.NewStyle().Bold(true).Render(l)
|
||
}
|
||
b.WriteString(" " + l + "\n")
|
||
}
|
||
if len(rows) == 0 {
|
||
b.WriteString(" " + sDim.Render("nothing yet") + "\n")
|
||
}
|
||
return b.String()
|
||
}
|