diff --git a/internal/cli/format.go b/internal/cli/format.go new file mode 100644 index 0000000..716d106 --- /dev/null +++ b/internal/cli/format.go @@ -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 "???" +} diff --git a/internal/cli/render.go b/internal/cli/render.go new file mode 100644 index 0000000..2269329 --- /dev/null +++ b/internal/cli/render.go @@ -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) +}