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>
This commit is contained in:
275
internal/style/table.go
Normal file
275
internal/style/table.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user