Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
261 lines
7.5 KiB
Go
261 lines
7.5 KiB
Go
package cli
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||
"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
|
||
// The version on every screen, top right: which build is being looked
|
||
// at is the first question about anything it shows.
|
||
right := version.Version + " "
|
||
if f.Where != "" {
|
||
right = f.Where + " · " + right
|
||
}
|
||
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 navLines []string
|
||
cur := 0
|
||
for _, it := range f.Nav {
|
||
mark := " "
|
||
label := it.Label
|
||
if it.Heading {
|
||
navLines = append(navLines, sDim.Render(label))
|
||
continue
|
||
}
|
||
if it.Current || it.State == "current" {
|
||
cur = len(navLines)
|
||
}
|
||
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)
|
||
}
|
||
navLines = append(navLines, truncateWidth(mark+label, navWidth-2))
|
||
}
|
||
// Taller than the screen: the part around the current entry, with a mark
|
||
// at an edge that has more beyond it.
|
||
if len(navLines) > inner {
|
||
total := len(navLines)
|
||
top := min(max(0, cur-inner/2), total-inner)
|
||
navLines = append([]string{}, navLines[top:top+inner]...)
|
||
if top > 0 {
|
||
navLines[0] = sDim.Render(" ↑ more")
|
||
}
|
||
if top+inner < total {
|
||
navLines[inner-1] = sDim.Render(" ↓ more")
|
||
}
|
||
}
|
||
left := box(fBorder, f.NavTitle, strings.Join(navLines, "\n"), 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()
|
||
}
|