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:
Steve Yegge
2025-12-22 17:52:32 -08:00
parent 469ba5c488
commit df0495be32
9 changed files with 822 additions and 43 deletions

232
internal/suggest/suggest.go Normal file
View File

@@ -0,0 +1,232 @@
// Package suggest provides fuzzy matching and "did you mean" suggestions.
package suggest
import (
"sort"
"strings"
"unicode"
)
// Match represents a potential match with its score.
type Match struct {
Value string
Score int
}
// FindSimilar finds similar strings from candidates that are close to target.
// Returns up to maxResults matches, sorted by similarity (best first).
func FindSimilar(target string, candidates []string, maxResults int) []string {
if len(candidates) == 0 || maxResults <= 0 {
return nil
}
target = strings.ToLower(target)
var matches []Match
for _, c := range candidates {
score := similarity(target, strings.ToLower(c))
if score > 0 {
matches = append(matches, Match{Value: c, Score: score})
}
}
// Sort by score descending
sort.Slice(matches, func(i, j int) bool {
return matches[i].Score > matches[j].Score
})
// Take top results
if len(matches) > maxResults {
matches = matches[:maxResults]
}
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m.Value
}
return result
}
// similarity calculates a similarity score between two strings.
// Higher is more similar. Uses a combination of techniques:
// - Prefix matching (high weight)
// - Contains matching (medium weight)
// - Levenshtein distance (for close matches)
// - Common substring matching
func similarity(a, b string) int {
if a == b {
return 1000 // Exact match
}
score := 0
// Prefix matching - high value
prefixLen := commonPrefixLength(a, b)
if prefixLen > 0 {
score += prefixLen * 20
}
// Suffix matching
suffixLen := commonSuffixLength(a, b)
if suffixLen > 0 {
score += suffixLen * 10
}
// Contains matching
if strings.Contains(b, a) {
score += len(a) * 15
} else if strings.Contains(a, b) {
score += len(b) * 10
}
// Levenshtein distance for close matches
dist := levenshteinDistance(a, b)
maxLen := max(len(a), len(b))
if maxLen > 0 && dist <= maxLen/2 {
// Closer distance = higher score
score += (maxLen - dist) * 5
}
// Common characters bonus (order-independent)
common := commonChars(a, b)
if common > 0 {
score += common * 2
}
// Penalize very different lengths
lenDiff := abs(len(a) - len(b))
if lenDiff > 5 {
score -= lenDiff * 2
}
return score
}
// commonPrefixLength returns the length of the common prefix.
func commonPrefixLength(a, b string) int {
minLen := min(len(a), len(b))
for i := 0; i < minLen; i++ {
if a[i] != b[i] {
return i
}
}
return minLen
}
// commonSuffixLength returns the length of the common suffix.
func commonSuffixLength(a, b string) int {
minLen := min(len(a), len(b))
for i := 0; i < minLen; i++ {
if a[len(a)-1-i] != b[len(b)-1-i] {
return i
}
}
return minLen
}
// commonChars counts characters that appear in both strings.
func commonChars(a, b string) int {
aChars := make(map[rune]int)
for _, r := range a {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
aChars[r]++
}
}
common := 0
for _, r := range b {
if count, ok := aChars[r]; ok && count > 0 {
common++
aChars[r]--
}
}
return common
}
// levenshteinDistance calculates the edit distance between two strings.
func levenshteinDistance(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
// Create distance matrix
d := make([][]int, len(a)+1)
for i := range d {
d[i] = make([]int, len(b)+1)
d[i][0] = i
}
for j := range d[0] {
d[0][j] = j
}
// Fill the matrix
for i := 1; i <= len(a); i++ {
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
d[i][j] = min3(
d[i-1][j]+1, // deletion
d[i][j-1]+1, // insertion
d[i-1][j-1]+cost, // substitution
)
}
}
return d[len(a)][len(b)]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min3(a, b, c int) int {
return min(min(a, b), c)
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// FormatSuggestion formats an error message with suggestions.
func FormatSuggestion(entity, name string, suggestions []string, createHint string) string {
var sb strings.Builder
sb.WriteString(entity)
sb.WriteString(" '")
sb.WriteString(name)
sb.WriteString("' not found")
if len(suggestions) > 0 {
sb.WriteString("\n\n Did you mean?\n")
for _, s := range suggestions {
sb.WriteString(" • ")
sb.WriteString(s)
sb.WriteString("\n")
}
}
if createHint != "" {
sb.WriteString("\n ")
sb.WriteString(createHint)
}
return sb.String()
}

View File

@@ -0,0 +1,120 @@
package suggest
import (
"strings"
"testing"
)
func TestFindSimilar(t *testing.T) {
tests := []struct {
name string
target string
candidates []string
maxResults int
wantFirst string // expect this to be the first result
}{
{
name: "exact prefix match",
target: "toa",
candidates: []string{"Toast", "Nux", "Capable", "Ghost"},
maxResults: 3,
wantFirst: "Toast",
},
{
name: "typo match",
target: "Tosat",
candidates: []string{"Toast", "Nux", "Capable"},
maxResults: 3,
wantFirst: "Toast",
},
{
name: "case insensitive",
target: "TOAST",
candidates: []string{"Nux", "Toast", "Capable"},
maxResults: 1,
wantFirst: "Toast", // finds Toast even with different case
},
{
name: "no matches",
target: "xyz",
candidates: []string{"abc", "def"},
maxResults: 3,
wantFirst: "", // no good matches
},
{
name: "empty candidates",
target: "test",
candidates: []string{},
maxResults: 3,
wantFirst: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := FindSimilar(tt.target, tt.candidates, tt.maxResults)
if tt.wantFirst == "" {
if len(results) > 0 {
// Allow some results for partial matches, just check they're reasonable
return
}
return
}
if len(results) == 0 {
t.Errorf("FindSimilar(%q) returned no results, want first = %q", tt.target, tt.wantFirst)
return
}
if results[0] != tt.wantFirst {
t.Errorf("FindSimilar(%q) first result = %q, want %q", tt.target, results[0], tt.wantFirst)
}
})
}
}
func TestLevenshteinDistance(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "a", 1},
{"abc", "abc", 0},
{"abc", "abd", 1},
{"abc", "adc", 1},
{"abc", "abcd", 1},
{"kitten", "sitting", 3},
}
for _, tt := range tests {
t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
got := levenshteinDistance(tt.a, tt.b)
if got != tt.want {
t.Errorf("levenshteinDistance(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestFormatSuggestion(t *testing.T) {
msg := FormatSuggestion("Polecat", "Tosat", []string{"Toast", "Ghost"}, "Create with: gt polecat add Tosat")
if !strings.Contains(msg, "Polecat") {
t.Errorf("FormatSuggestion missing entity name")
}
if !strings.Contains(msg, "Tosat") {
t.Errorf("FormatSuggestion missing target name")
}
if !strings.Contains(msg, "Did you mean?") {
t.Errorf("FormatSuggestion missing 'Did you mean?' section")
}
if !strings.Contains(msg, "Toast") {
t.Errorf("FormatSuggestion missing suggestion 'Toast'")
}
if !strings.Contains(msg, "Create with:") {
t.Errorf("FormatSuggestion missing hint")
}
}