Files
llmgw/internal/cli/tui_frame.go
Ilker ManapandClaude Opus 5.5 d311a3ca4a
CI / test (push) Successful in 4m50s
Show the version top right on every screen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:18:45 +02:00

261 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}