Documentation
¶
Overview ¶
Package ui provides the shared Charm-expressive theme used by both inline-mode CLI output and the TUI. Every exported style is safe to copy and tweak via lipgloss's builder API without mutating defaults.
All colors are lipgloss.AdaptiveColor pairs so light-background terminals stay readable; lipgloss handles NO_COLOR and color-profile degradation automatically.
Index ¶
- Constants
- Variables
- func ActionLine(primary, secondary string) string
- func BrailleGraph(values []int64, width, height int) []string
- func FormatBytes(n int64) string
- func Gauge(frac float64, width int) string
- func GradientColors(stops []string, n int) []string
- func PromptPassphrase(prompt string, minLen int) ([]byte, error)
- func PromptPassphraseWithConfirm(prompt string, minLen int) ([]byte, error)
- func RenderTable(headers []string, rows [][]string) string
- func SectionTitle(label string) string
- func SelectRow(selected bool, label string) string
- func Severity(level string) lipgloss.Style
- func TableStyles() table.Styles
- func TableView(t table.Model) string
- type ByteProgress
Constants ¶
const TableGutter = 2
TableGutter is the width in cells TableView adds to every line of a table: the marker and the space lipgloss joins it to the row with. A view that budgets its columns to the pane interior must subtract it, or the table overflows the pane by exactly this much.
Variables ¶
var ( AccentViolet = lipgloss.AdaptiveColor{Dark: "#CB8CFF", Light: "#7C3AED"} // electric purple AccentPink = lipgloss.AdaptiveColor{Dark: "#FF6BDD", Light: "#DB2777"} // hot magenta AccentAqua = lipgloss.AdaptiveColor{Dark: "#5CEBFF", Light: "#0E7490"} // laser cyan GoodGreen = lipgloss.AdaptiveColor{Dark: "#5CFFB4", Light: "#059669"} // neon mint WarnAmber = lipgloss.AdaptiveColor{Dark: "#FFD84D", Light: "#D97706"} // sunset gold BadRed = lipgloss.AdaptiveColor{Dark: "#FF6B86", Light: "#DC2626"} // neon red FgMuted = lipgloss.AdaptiveColor{Dark: "#8E7DC8", Light: "#6B7280"} // dim indigo FgSubtle = lipgloss.AdaptiveColor{Dark: "#CBB8FF", Light: "#9CA3AF"} // soft lilac )
Palette — synthwave / outrun. Dark variants are saturated neons that glow against a dark terminal (electric purple, hot magenta, laser cyan, a sunset gold); Light variants stay legible on white terminals. lipgloss picks per background and strips to nothing under Ascii/NO_COLOR, so the neons are a dark-terminal flourish, never a legibility risk elsewhere.
var ( Primary = lipgloss.NewStyle().Foreground(AccentViolet).Bold(true) Success = lipgloss.NewStyle().Foreground(GoodGreen).Bold(true) Warn = lipgloss.NewStyle().Foreground(WarnAmber).Bold(true) Danger = lipgloss.NewStyle().Foreground(BadRed).Bold(true) Muted = lipgloss.NewStyle().Foreground(FgMuted) Subtle = lipgloss.NewStyle().Foreground(FgSubtle) Panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(AccentViolet).Padding(0, 1) )
Semantic styles (pre-existing names; CLI callers depend on them). The status accents are bold so a neon foreground reads like lit signage rather than a thin stroke — the "glow" of the synthwave theme. Muted/Subtle stay unweighted; secondary text should recede, not glow.
var ( // TitleBar renders the top brand bar: pink brand on a violet- // bordered row. (Gradients aren't native to lipgloss v1; the // pink-on-violet pairing is the approved approximation.) TitleBar = lipgloss.NewStyle().Foreground(AccentPink).Bold(true).Padding(0, 1) // SidebarItem / SidebarActive style nav-rail entries. The active // marker "▍" is part of the style contract (selection stays visible // with colors stripped) — the sidebar prepends it when rendering. SidebarItem = lipgloss.NewStyle().Foreground(FgSubtle).PaddingLeft(2) SidebarActive = lipgloss.NewStyle().Foreground(AccentPink).Bold(true). SetString("▍ ") // StatusBar styles the bottom hint row. StatusBar = lipgloss.NewStyle().Foreground(FgMuted).Padding(0, 1) // ModalBox frames modal overlays (confirm / error dialogs). ModalBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(AccentPink).Padding(1, 2) // PanelFocused is Panel with the aqua focus accent, for the pane // that currently owns keyboard focus. PanelFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(AccentAqua).Padding(0, 1) // FieldBox frames the one text field currently accepting input. The // border is the focus affordance itself — a glyph, visible without // color — so views must apply it only to the focused field. Padding // matches Panel so boxed fields align with panel content. // // DO NOT add a Foreground (or any other text-level attribute) here. // Callers wrap an ALREADY-STYLED textinput.View(), which is only safe // while this style touches the border alone: Render then emits SGR on // the border runes and passes the content through untouched. Give it a // Foreground and the content comes back as // "�[92m�[91mINNER�[0m�[0m" — the inner reset closes the // OUTER style too, which is exactly the house "never wrap an // already-styled string" bug. FieldBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(AccentAqua).Padding(0, 1) // FieldBoxOverhead is the horizontal cost of wrapping a field in // FieldBox (2 border columns + 2 padding). A call site that sizes its // textinput from the pane's interior must subtract this, or the framed // line overflows the panel the moment the field takes focus. Derived // from the style rather than written as a literal 4 so it can't drift // if FieldBox's border or padding ever changes. FieldBoxOverhead = FieldBox.GetHorizontalFrameSize() // TableHeader styles a bubbles/table header cell: bold aqua text over // a muted rule. The rule is the affordance that survives NO_COLOR — // bubbles' default header is merely bold, which lipgloss strips in // the Ascii profile, leaving the header indistinguishable from the // rows beneath it. Padding matches bubbles' default Cell style so // header and cells stay column-aligned; the border rides under the // padding, so adjacent cells' rules join into one unbroken line. // The rule makes the header two rows tall; bubbles' SetHeight budgets // the whole table, header included, so callers need not adjust — the // viewport simply shows one row fewer. TableHeader = lipgloss.NewStyle().Foreground(AccentAqua).Bold(true).Padding(0, 1). BorderStyle(lipgloss.NormalBorder()).BorderBottom(true). BorderForeground(FgMuted) )
Shell styles (TUI chrome).
var ErrNotATTY = errors.New("ui: passphrase prompt requires a TTY; pass the passphrase via --passphrase-file, SENTRA_PASSPHRASE, or the OS keyring")
ErrNotATTY is returned by the passphrase prompts when stdin is not a terminal. Callers should resolve the passphrase via the flag, SENTRA_PASSPHRASE env var, or OS keyring before falling back to a prompt.
var ErrPassphraseMismatch = errors.New("ui: passphrase entries did not match")
ErrPassphraseMismatch is returned by PromptPassphraseWithConfirm when the two entries differ.
var ErrPassphraseTooShort = errors.New("ui: passphrase is shorter than the configured minimum")
ErrPassphraseTooShort is returned when the user enters a passphrase shorter than the configured minimum.
Functions ¶
func ActionLine ¶
ActionLine renders a view's footer: the primary action in the accent style, then the secondary keys demoted to muted on the line below. It is the one place the "Press enter to …" convention lives, so every view reads the same.
primary is a verb phrase without the ⏎ glyph — "start the backup", "run the integrity check" — which ActionLine renders as "⏎ Press enter to start the backup". An empty primary drops the accent line (a view with no enter action, only secondary keys). secondary may be empty.
It returns the two lines with NO leading newline; the caller places it.
func BrailleGraph ¶
BrailleGraph renders values as a filled area graph, width cells wide and height cells tall, returning height lines top-first. Scaling is zero-based (0 → empty, series max → full height) rather than min-max: byte sizes have a true zero, and min-max would inflate tiny fluctuations into a full-height mountain range on a repo whose backups are all about the same size.
The series is resampled to the 2*width sub-columns the braille grid provides: a longer series is downsampled by bucket MAX (an anomalous spike is exactly what must not smooth away), a shorter one is stretched so few snapshots still fill the panel instead of leaving it mostly empty. Negative values clamp to the floor.
func FormatBytes ¶
FormatBytes returns a human-readable string for n bytes, using binary (IEC) units up to PiB. Values below 1 KiB are rendered as raw bytes; larger values use one decimal place.
Negative values pass through with the sign preserved so callers (e.g. snapshot diffs showing freed space) keep accurate readouts.
func Gauge ¶
Gauge renders a horizontal fill bar: round(frac*width) filled cells (█) then empty cells (░). frac is clamped to [0,1]. The filled/empty distinction is glyph shape, never color alone, so it stays legible under NO_COLOR and in the Ascii profile the unit tests render in.
func GradientColors ¶
GradientColors returns n hex colors sampled evenly through the given stop list — the vertical ramp the dashboard paints up the braille graph (bottom stop → top stop) and along its meter bars. Endpoints are exact; interior samples blend linearly between adjacent stops. Returns nil for n<=0 or no stops. A single stop repeats.
Colors are #rrggbb so lipgloss emits truecolor SGR; under the Ascii profile (tests, NO_COLOR, pipes) lipgloss strips them and the graph falls back to its plain, still-legible braille.
func PromptPassphrase ¶
PromptPassphrase reads a passphrase from the terminal with input echoed as asterisks. Returns ErrNotATTY when stdin is not a terminal so scripted callers fail fast instead of hanging on a stalled prompt.
minLen is the minimum acceptable passphrase length. Pass 0 to disable the check.
func PromptPassphraseWithConfirm ¶
PromptPassphraseWithConfirm prompts twice and verifies the two entries match before returning. Used during sentra init to catch typos before they bake into a wrapped repo key. Comparison is constant-time — overkill for typed input, but cheap and tidy.
func RenderTable ¶
RenderTable returns a styled string representation of headers + rows. Each row shorter than headers is padded with empty strings; each row longer is truncated. An empty rows slice still renders the header row, giving callers a consistent "no data" view rather than blank.
Headers are styled with the package's Primary accent. Cells are padded by one space on each side; we let lipgloss/table own the framing characters so output adapts cleanly to terminal width.
func SectionTitle ¶
SectionTitle renders a panel/section heading so it reads as chrome, not data: UPPERCASE in a bold accent. Uppercasing is the load-bearing half — bold and color vanish under NO_COLOR, a pipe, and the Ascii profile the tests run in; the capitals survive everywhere. Same rationale as SelectRow's glyph marker below.
func SelectRow ¶
SelectRow renders one row of a selectable list: the "▍" marker plus the accent color when selected, two spaces and the subtle color when not. Both states are two cells wide before the label, so the column never shifts as the cursor moves.
The marker is load-bearing, not decoration. Colors vanish under NO_COLOR, a pipe, or a 2-color terminal — and lipgloss renders no ANSI at all under the Ascii profile every unit test runs in. The marker is what keeps the selection legible, and testable, in all of them.
label must be UNSTYLED. Wrapping an already-styled string embeds an ANSI reset that terminates the outer style mid-line, silently un-coloring the tail of the row. Append muted help text after the call, outside the styled span.
func Severity ¶
Severity returns the style for an agent finding's severity level. Unrecognized levels (including "") map to Muted so callers can pass raw values without special-casing.
func TableStyles ¶ added in v0.5.0
TableStyles is bubbles' default table styling with TableHeader in place of the plain bold header and the selection glyph in place of the colour-only Selected. Cell stays at its default so the data rows read as data. This is the ONE place a table is styled: every table in the TUI takes these and renders through TableView, which turns the glyph into an aligned gutter.
bubbles' own Selected is bold plus a foreground colour and nothing else: under NO_COLOR, a pipe, or the Ascii profile every unit test runs in, lipgloss emits no ANSI at all, so the cursor row was indistinguishable from its neighbours. SetString makes the style itself emit the marker ahead of the row (lipgloss joins the set string and the content with a space), which survives every profile. bubbles/table has no row-render hook, so its Selected style is the only seam that sees the cursor row.
func TableView ¶ added in v0.5.0
TableView renders t with the selection gutter: the cursor row keeps the marker its Selected style emitted, and every other line — header and rule included — is indented by TableGutter so the columns stay aligned. Callers use this in place of t.View(); through the raw View the cursor row sits two cells right of every other row. A table rendered through it must never have SetWidth applied — the gutter would be truncated off the cursor row; budget the columns to the pane instead.
Types ¶
type ByteProgress ¶
type ByteProgress struct {
// contains filtered or unexported fields
}
ByteProgress wraps bubbles/progress with byte-formatted output. The underlying bubbles model owns the bar animation; this wrapper just translates byte counts to a percentage and composes a final render line that includes the human-readable done/total counts.
ByteProgress is intended for two consumption modes:
- Inline (non-tea): a CLI command repeatedly calls SetDone + Render and prints the result, ignoring the returned tea.Cmd.
- Tea program: a Bubble Tea model embeds *ByteProgress and dispatches the returned tea.Cmd through its Update loop so the bar animates smoothly between updates.
func NewByteProgress ¶
func NewByteProgress(total int64) *ByteProgress
NewByteProgress creates a progress component sized in bytes. A total of zero is allowed and is treated as "already complete"; Render will report 100%.
func (*ByteProgress) Add ¶
func (p *ByteProgress) Add(delta int64)
Add binds *ByteProgress to the progress.Reporter interface. It applies a positive delta to the done count under the mutex; the underlying bubbles progress model is NOT touched here because progress.Model.SetPercent is not concurrency-safe and Add is called from the walker's worker pool. The bar is repainted at Render time, which inline mode calls from a single ticker goroutine — the only place SetPercent is safely callable.
Concurrent calls are safe: the load + store of done is serialized under p.mu so two walker workers never observe a torn value.
func (*ByteProgress) Render ¶
func (p *ByteProgress) Render() string
Render returns the formatted progress string. Layout:
<bar> 12.3 MiB / 50.4 MiB (24%)
The percentage in the trailing parens is integer-rounded; the bar itself includes its own continuous percentage from bubbles. We use integer percent here so smoke tests have a stable substring.
Render is the single place that touches the underlying bubbles progress.Model — that model isn't concurrency-safe, so Add only updates the byte counters and we drive the model from Render instead. Inline-mode callers paint from one goroutine (the ticker), so this is fine; tea-mode callers should drive the model through SetDone in their Update loop.
func (*ByteProgress) SetDone ¶
func (p *ByteProgress) SetDone(done int64) tea.Cmd
SetDone updates the done-bytes count. Returns a tea.Cmd that drives the bar's animation; inline (non-tea) callers may discard it.
done is clamped to [0, total]. If total is zero, the bar is set to 100% regardless of done.
func (*ByteProgress) Total ¶
func (p *ByteProgress) Total(n int64)
Total binds *ByteProgress to the progress.Reporter interface so a CLI command can pass it to repo.CreateSnapshot directly.