feat: add CLI formatting utilities and table renderer

Implement the presentation layer for terminal output:

- cli/format.go: Human-readable formatting functions — FormatTokens
  (K/M/B suffixes), FormatCost (adaptive precision: $X.XX under $10,
  $X.X under $100, $X over $100, comma-separated over $1000),
  FormatDuration (Xh Ym / Xm / Xs), FormatNumber (comma-separated
  integers), FormatPercent (0-1 -> "XX.X%"), FormatDelta (signed
  cost delta with +/- prefix), FormatDayOfWeek (weekday number to
  3-letter abbreviation).

- cli/render.go: lipgloss-styled output components using the Flexoki
  Dark color palette:

  * RenderTitle: centered title in a rounded border box.

  * RenderTable: full-featured bordered table with auto-calculated
    column widths, right-aligned numeric columns (all except first),
    separator rows (triggered by "---" sentinel), and proper Unicode
    box-drawing characters. Supports optional title header and
    explicit column widths.

  * RenderProgressBar: bracketed fill bar with current/total counts.

  * RenderSparkline: Unicode block sparkline (8-level: ▁ through █)
    from arbitrary float64 series, auto-scaled to max.

  * RenderHorizontalBar: simple horizontal bar chart entry for
    inline comparisons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-19 13:01:56 -05:00
parent 24454247a3
commit f7d8fea140
2 changed files with 391 additions and 0 deletions

110
internal/cli/format.go Normal file
View File

@@ -0,0 +1,110 @@
package cli
import (
"fmt"
"math"
"strings"
)
// FormatTokens formats a token count with human-readable suffixes.
// e.g., 1234 -> "1.2K", 1234567 -> "1.2M", 1234567890 -> "1.2B"
func FormatTokens(n int64) string {
abs := n
if abs < 0 {
abs = -abs
}
switch {
case abs >= 1_000_000_000:
return fmt.Sprintf("%.1fB", float64(n)/1_000_000_000)
case abs >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
case abs >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1_000)
default:
return fmt.Sprintf("%d", n)
}
}
// FormatCost formats a USD cost value.
func FormatCost(cost float64) string {
if cost >= 1000 {
return fmt.Sprintf("$%s", FormatNumber(int64(math.Round(cost))))
}
if cost >= 100 {
return fmt.Sprintf("$%.0f", cost)
}
if cost >= 10 {
return fmt.Sprintf("$%.1f", cost)
}
return fmt.Sprintf("$%.2f", cost)
}
// FormatDuration formats seconds into a human-readable duration.
// e.g., 3725 -> "1h 2m", 125 -> "2m", 45 -> "45s"
func FormatDuration(secs int64) string {
if secs <= 0 {
return "0s"
}
hours := secs / 3600
mins := (secs % 3600) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
if mins > 0 {
return fmt.Sprintf("%dm", mins)
}
return fmt.Sprintf("%ds", secs)
}
// FormatNumber adds comma separators to an integer.
// e.g., 1234567 -> "1,234,567"
func FormatNumber(n int64) string {
if n < 0 {
return "-" + FormatNumber(-n)
}
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var result strings.Builder
remainder := len(s) % 3
if remainder > 0 {
result.WriteString(s[:remainder])
}
for i := remainder; i < len(s); i += 3 {
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3])
}
return result.String()
}
// FormatPercent formats a 0-1 float as a percentage string.
func FormatPercent(f float64) string {
return fmt.Sprintf("%.1f%%", f*100)
}
// FormatDelta formats a cost delta with sign and color hint.
// Returns the formatted string and whether it's positive.
func FormatDelta(current, previous float64) string {
delta := current - previous
if delta >= 0 {
return fmt.Sprintf("+%s", FormatCost(delta))
}
return fmt.Sprintf("-%s", FormatCost(-delta))
}
// FormatDayOfWeek returns a 3-letter day abbreviation from a weekday number.
func FormatDayOfWeek(weekday int) string {
days := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
if weekday >= 0 && weekday < 7 {
return days[weekday]
}
return "???"
}

281
internal/cli/render.go Normal file
View File

@@ -0,0 +1,281 @@
package cli
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Theme colors (Flexoki Dark)
var (
ColorBg = lipgloss.Color("#100F0F")
ColorSurface = lipgloss.Color("#1C1B1A")
ColorBorder = lipgloss.Color("#282726")
ColorTextDim = lipgloss.Color("#575653")
ColorTextMuted = lipgloss.Color("#6F6E69")
ColorText = lipgloss.Color("#FFFCF0")
ColorAccent = lipgloss.Color("#3AA99F")
ColorGreen = lipgloss.Color("#879A39")
ColorOrange = lipgloss.Color("#DA702C")
ColorRed = lipgloss.Color("#D14D41")
ColorBlue = lipgloss.Color("#4385BE")
ColorPurple = lipgloss.Color("#8B7EC8")
ColorYellow = lipgloss.Color("#D0A215")
)
// Styles
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorText).
Align(lipgloss.Center)
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorAccent)
valueStyle = lipgloss.NewStyle().
Foreground(ColorText)
mutedStyle = lipgloss.NewStyle().
Foreground(ColorTextMuted)
costStyle = lipgloss.NewStyle().
Foreground(ColorGreen)
tokenStyle = lipgloss.NewStyle().
Foreground(ColorBlue)
warnStyle = lipgloss.NewStyle().
Foreground(ColorOrange)
dimStyle = lipgloss.NewStyle().
Foreground(ColorTextDim)
)
// Table represents a bordered text table for CLI output.
type Table struct {
Title string
Headers []string
Rows [][]string
Widths []int // optional column widths, auto-calculated if nil
}
// RenderTitle renders a centered title bar in a bordered box.
func RenderTitle(title string) string {
width := 55
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorBorder).
Width(width).
Align(lipgloss.Center).
Padding(0, 1)
return border.Render(titleStyle.Render(title))
}
// RenderTable renders a bordered table with headers and rows.
func RenderTable(t Table) string {
if len(t.Rows) == 0 && len(t.Headers) == 0 {
return ""
}
// Calculate column widths
numCols := len(t.Headers)
if numCols == 0 && len(t.Rows) > 0 {
numCols = len(t.Rows[0])
}
widths := make([]int, numCols)
if t.Widths != nil {
copy(widths, t.Widths)
} else {
for i, h := range t.Headers {
if len(h) > widths[i] {
widths[i] = len(h)
}
}
for _, row := range t.Rows {
for i, cell := range row {
if i < numCols && len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
}
var b strings.Builder
// Title above table if present
if t.Title != "" {
b.WriteString(" ")
b.WriteString(headerStyle.Render(t.Title))
b.WriteString("\n")
}
totalWidth := 1 // left border
for _, w := range widths {
totalWidth += w + 3 // padding + separator
}
// Top border
b.WriteString(dimStyle.Render("╭"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┬"))
}
}
b.WriteString(dimStyle.Render("╮"))
b.WriteString("\n")
// Header row
if len(t.Headers) > 0 {
b.WriteString(dimStyle.Render("│"))
for i, h := range t.Headers {
w := widths[i]
padded := fmt.Sprintf(" %-*s ", w, h)
b.WriteString(headerStyle.Render(padded))
if i < numCols-1 {
b.WriteString(dimStyle.Render("│"))
}
}
b.WriteString(dimStyle.Render("│"))
b.WriteString("\n")
// Header separator
b.WriteString(dimStyle.Render("├"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┼"))
}
}
b.WriteString(dimStyle.Render("┤"))
b.WriteString("\n")
}
// Data rows
for _, row := range t.Rows {
if len(row) == 1 && row[0] == "---" {
// Separator row
b.WriteString(dimStyle.Render("├"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┼"))
}
}
b.WriteString(dimStyle.Render("┤"))
b.WriteString("\n")
continue
}
b.WriteString(dimStyle.Render("│"))
for i := 0; i < numCols; i++ {
w := widths[i]
cell := ""
if i < len(row) {
cell = row[i]
}
// Right-align numeric columns (all except first)
var padded string
if i == 0 {
padded = fmt.Sprintf(" %-*s ", w, cell)
} else {
padded = fmt.Sprintf(" %*s ", w, cell)
}
b.WriteString(valueStyle.Render(padded))
if i < numCols-1 {
b.WriteString(dimStyle.Render("│"))
}
}
b.WriteString(dimStyle.Render("│"))
b.WriteString("\n")
}
// Bottom border
b.WriteString(dimStyle.Render("╰"))
for i, w := range widths {
b.WriteString(dimStyle.Render(strings.Repeat("─", w+2)))
if i < numCols-1 {
b.WriteString(dimStyle.Render("┴"))
}
}
b.WriteString(dimStyle.Render("╯"))
b.WriteString("\n")
return b.String()
}
// RenderProgressBar renders a simple text progress bar.
func RenderProgressBar(current, total int, width int) string {
if total <= 0 {
return ""
}
pct := float64(current) / float64(total)
if pct > 1 {
pct = 1
}
filled := int(pct * float64(width))
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
return fmt.Sprintf("[%s] %s/%s",
mutedStyle.Render(bar),
FormatNumber(int64(current)),
FormatNumber(int64(total)),
)
}
// RenderSparkline generates a unicode block sparkline from a series of values.
func RenderSparkline(values []float64) string {
if len(values) == 0 {
return ""
}
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
max := values[0]
for _, v := range values[1:] {
if v > max {
max = v
}
}
if max == 0 {
max = 1
}
var b strings.Builder
for _, v := range values {
idx := int(v / max * float64(len(blocks)-1))
if idx >= len(blocks) {
idx = len(blocks) - 1
}
if idx < 0 {
idx = 0
}
b.WriteRune(blocks[idx])
}
return b.String()
}
// RenderHorizontalBar renders a horizontal bar chart entry.
func RenderHorizontalBar(label string, value, maxValue float64, maxWidth int) string {
if maxValue <= 0 {
return fmt.Sprintf(" %s", label)
}
barLen := int(value / maxValue * float64(maxWidth))
if barLen < 0 {
barLen = 0
}
bar := strings.Repeat("█", barLen)
return fmt.Sprintf(" %s", bar)
}