Files
gastown/internal/style/table.go
Steve Yegge df0495be32 feat(tui): add self-documenting help with ASCII diagrams and table helpers
TUI improvements for Christmas launch:
- Add phase transition table and lifecycle diagram to `gt molecule --help`
- Add swarm lifecycle diagram to `gt swarm --help`
- Add mail routing diagram to `gt mail --help`
- Add sling mechanics diagram to `gt sling --help`
- Create Lipgloss table helper (internal/style/table.go)
- Migrate mq_list to use styled tables with color-coded priorities
- Migrate molecule list to use styled tables
- Add fuzzy matching "did you mean" suggestions for polecat not found errors
- Add suggest package with Levenshtein distance implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 17:52:44 -08:00

276 lines
7.0 KiB
Go

// Package style provides consistent terminal styling using Lipgloss.
package style
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Column defines a table column with name and width.
type Column struct {
Name string
Width int
Align Alignment
Style lipgloss.Style
}
// Alignment specifies column text alignment.
type Alignment int
const (
AlignLeft Alignment = iota
AlignRight
AlignCenter
)
// Table provides styled table rendering.
type Table struct {
columns []Column
rows [][]string
headerSep bool
indent string
headerStyle lipgloss.Style
}
// NewTable creates a new table with the given columns.
func NewTable(columns ...Column) *Table {
return &Table{
columns: columns,
headerSep: true,
indent: " ",
headerStyle: Bold,
}
}
// SetIndent sets the left indent for the table.
func (t *Table) SetIndent(indent string) *Table {
t.indent = indent
return t
}
// SetHeaderSeparator enables/disables the header separator line.
func (t *Table) SetHeaderSeparator(enabled bool) *Table {
t.headerSep = enabled
return t
}
// AddRow adds a row of values to the table.
func (t *Table) AddRow(values ...string) *Table {
// Pad with empty strings if needed
for len(values) < len(t.columns) {
values = append(values, "")
}
t.rows = append(t.rows, values)
return t
}
// Render returns the formatted table string.
func (t *Table) Render() string {
if len(t.columns) == 0 {
return ""
}
var sb strings.Builder
// Render header
sb.WriteString(t.indent)
for i, col := range t.columns {
text := t.headerStyle.Render(col.Name)
sb.WriteString(t.pad(text, col.Name, col.Width, col.Align))
if i < len(t.columns)-1 {
sb.WriteString(" ")
}
}
sb.WriteString("\n")
// Render separator
if t.headerSep {
sb.WriteString(t.indent)
totalWidth := 0
for i, col := range t.columns {
totalWidth += col.Width
if i < len(t.columns)-1 {
totalWidth++ // space between columns
}
}
sb.WriteString(Dim.Render(strings.Repeat("─", totalWidth)))
sb.WriteString("\n")
}
// Render rows
for _, row := range t.rows {
sb.WriteString(t.indent)
for i, col := range t.columns {
val := ""
if i < len(row) {
val = row[i]
}
// Truncate if too long
plainVal := stripAnsi(val)
if len(plainVal) > col.Width {
val = plainVal[:col.Width-3] + "..."
}
// Apply column style if set
if col.Style.Value() != "" {
val = col.Style.Render(val)
}
sb.WriteString(t.pad(val, plainVal, col.Width, col.Align))
if i < len(t.columns)-1 {
sb.WriteString(" ")
}
}
sb.WriteString("\n")
}
return sb.String()
}
// pad pads text to width, accounting for ANSI escape sequences.
// styledText is the text with ANSI codes, plainText is without.
func (t *Table) pad(styledText, plainText string, width int, align Alignment) string {
plainLen := len(plainText)
if plainLen >= width {
return styledText
}
padding := width - plainLen
switch align {
case AlignRight:
return strings.Repeat(" ", padding) + styledText
case AlignCenter:
left := padding / 2
right := padding - left
return strings.Repeat(" ", left) + styledText + strings.Repeat(" ", right)
default: // AlignLeft
return styledText + strings.Repeat(" ", padding)
}
}
// stripAnsi removes ANSI escape sequences from a string.
func stripAnsi(s string) string {
var result strings.Builder
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' {
inEscape = true
continue
}
if inEscape {
if s[i] == 'm' {
inEscape = false
}
continue
}
result.WriteByte(s[i])
}
return result.String()
}
// PhaseTable renders the molecule phase transition table.
func PhaseTable() string {
return `
Phase Flow:
discovery ──┬──→ structural ──→ tactical ──→ synthesis
│ (sequential) (parallel) (single)
└─── (parallel)
┌─────────────┬─────────────┬─────────────┬─────────────────────┐
│ Phase │ Parallelism │ Blocks │ Purpose │
├─────────────┼─────────────┼─────────────┼─────────────────────┤
│ discovery │ full │ (nothing) │ Inventory, gather │
│ structural │ sequential │ discovery │ Big-picture review │
│ tactical │ parallel │ structural │ Detailed work │
│ synthesis │ single │ tactical │ Aggregate results │
└─────────────┴─────────────┴─────────────┴─────────────────────┘`
}
// MoleculeLifecycleASCII renders the molecule lifecycle diagram.
func MoleculeLifecycleASCII() string {
return `
Proto (template)
▼ bond
┌─────────────────┐
│ Mol (durable) │
│ Wisp (ephemeral)│
└────────┬────────┘
┌──────┴──────┐
▼ ▼
burn squash
(no record) (→ digest)`
}
// DAGProgress renders a DAG progress visualization.
// steps is a map of step name to status (done, in_progress, ready, blocked).
func DAGProgress(steps map[string]string, phases []string) string {
var sb strings.Builder
icons := map[string]string{
"done": "✓",
"in_progress": "⧖",
"ready": "○",
"blocked": "◌",
}
colors := map[string]lipgloss.Style{
"done": Success,
"in_progress": Warning,
"ready": Info,
"blocked": Dim,
}
for _, phase := range phases {
sb.WriteString(fmt.Sprintf(" %s\n", Bold.Render(phase)))
for name, status := range steps {
if strings.HasPrefix(name, phase+"-") || strings.HasPrefix(name, phase+"/") {
icon := icons[status]
style := colors[status]
stepName := strings.TrimPrefix(strings.TrimPrefix(name, phase+"-"), phase+"/")
sb.WriteString(fmt.Sprintf(" %s %s\n", style.Render(icon), stepName))
}
}
}
return sb.String()
}
// SuggestionBox renders a "did you mean" suggestion box.
func SuggestionBox(message string, suggestions []string, hint string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n%s %s\n", ErrorPrefix, message))
if len(suggestions) > 0 {
sb.WriteString("\n Did you mean?\n")
for _, s := range suggestions {
sb.WriteString(fmt.Sprintf(" • %s\n", s))
}
}
if hint != "" {
sb.WriteString(fmt.Sprintf("\n %s\n", Dim.Render(hint)))
}
return sb.String()
}
// ProgressBar renders a simple progress bar.
func ProgressBar(percent int, width int) string {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := (percent * width) / 100
empty := width - filled
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
return fmt.Sprintf("[%s] %d%%", bar, percent)
}