From df0495be322bbb088294eff2edea28479eb66ddd Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 17:52:32 -0800 Subject: [PATCH] feat(tui): add self-documenting help with ASCII diagrams and table helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cmd/mail.go | 33 +++- internal/cmd/molecule.go | 62 ++++++- internal/cmd/mq_list.go | 51 ++++-- internal/cmd/session.go | 5 +- internal/cmd/sling.go | 50 ++++-- internal/cmd/swarm.go | 37 ++++- internal/style/table.go | 275 +++++++++++++++++++++++++++++++ internal/suggest/suggest.go | 232 ++++++++++++++++++++++++++ internal/suggest/suggest_test.go | 120 ++++++++++++++ 9 files changed, 822 insertions(+), 43 deletions(-) create mode 100644 internal/style/table.go create mode 100644 internal/suggest/suggest.go create mode 100644 internal/suggest/suggest_test.go diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 92e8de20..b7e3dbfa 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -43,7 +43,38 @@ var mailCmd = &cobra.Command{ Long: `Send and receive messages between agents. The mail system allows Mayor, polecats, and the Refinery to communicate. -Messages are stored in beads as issues with type=message.`, +Messages are stored in beads as issues with type=message. + +MAIL ROUTING: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Town (.beads/) β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Mayor Inbox β”‚ β”‚ + β”‚ β”‚ └── mayor/ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ gastown/ (rig mailboxes) β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€ witness ← gastown/witness β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€ refinery ← gastown/refinery β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€ Toast ← gastown/Toast β”‚ β”‚ + β”‚ β”‚ └── crew/max ← gastown/crew/max β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +ADDRESS FORMATS: + mayor/ β†’ Mayor inbox + /witness β†’ Rig's Witness + /refinery β†’ Rig's Refinery + / β†’ Polecat (e.g., gastown/Toast) + /crew/ β†’ Crew worker (e.g., gastown/crew/max) + --human β†’ Special: human overseer + +COMMANDS: + inbox View your inbox + send Send a message + read Read a specific message + mark Mark messages read/unread`, } var mailSendCmd = &cobra.Command{ diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index a4906792..187faf5b 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -30,7 +30,39 @@ var moleculeCmd = &cobra.Command{ Long: `Manage molecule workflow templates. Molecules are composable workflow patterns stored as beads issues. -When instantiated on a parent issue, they create child beads forming a DAG.`, +When instantiated on a parent issue, they create child beads forming a DAG. + +LIFECYCLE: + Proto (template) + β”‚ + β–Ό instantiate/bond + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Mol (durable) β”‚ ← tracked in .beads/ + β”‚ Wisp (ephemeral)β”‚ ← tracked in .beads-wisp/ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό + burn squash + (no record) (β†’ digest) + +PHASE TRANSITIONS (for pluggable molecules): + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ 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 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +COMMANDS: + catalog List available molecule protos + instantiate Create steps from a molecule template + progress Show execution progress of an instantiated molecule + status Show what's on an agent's hook + burn Discard molecule without creating a digest + squash Complete molecule and create a digest`, } var moleculeListCmd = &cobra.Command{ @@ -386,23 +418,35 @@ func runMoleculeList(cmd *cobra.Command, args []string) error { return nil } + // Create styled table + table := style.NewTable( + style.Column{Name: "ID", Width: 20}, + style.Column{Name: "TITLE", Width: 35}, + style.Column{Name: "STEPS", Width: 5, Align: style.AlignRight}, + style.Column{Name: "SOURCE", Width: 10}, + ) + for _, mol := range entries { - sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source)) - - stepCount := "" + // Format steps count + stepStr := "" if mol.StepCount > 0 { - stepCount = fmt.Sprintf(" (%d steps)", mol.StepCount) + stepStr = fmt.Sprintf("%d", mol.StepCount) } - statusMarker := "" + // Format title with status + title := mol.Title if mol.Status == "closed" { - statusMarker = " " + style.Dim.Render("[closed]") + title = style.Dim.Render(mol.Title + " [closed]") } - fmt.Printf(" %s: %s%s%s %s\n", - style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker, sourceMarker) + // Format source + source := style.Dim.Render(mol.Source) + + table.AddRow(mol.ID, title, stepStr, source) } + fmt.Print(table.Render()) + return nil } diff --git a/internal/cmd/mq_list.go b/internal/cmd/mq_list.go index b83a8394..26caf277 100644 --- a/internal/cmd/mq_list.go +++ b/internal/cmd/mq_list.go @@ -104,12 +104,17 @@ func runMQList(cmd *cobra.Command, args []string) error { return nil } - // Print header - fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", - "ID", "STATUS", "PRIORITY", "BRANCH", "WORKER", "AGE") - fmt.Printf(" %s\n", strings.Repeat("-", 90)) + // Create styled table + table := style.NewTable( + style.Column{Name: "ID", Width: 12}, + style.Column{Name: "STATUS", Width: 12}, + style.Column{Name: "PRI", Width: 4}, + style.Column{Name: "BRANCH", Width: 28}, + style.Column{Name: "WORKER", Width: 10}, + style.Column{Name: "AGE", Width: 6, Align: style.AlignRight}, + ) - // Print each MR + // Add rows for _, issue := range filtered { fields := beads.ParseMRFields(issue) @@ -127,9 +132,9 @@ func runMQList(cmd *cobra.Command, args []string) error { styledStatus := displayStatus switch displayStatus { case "ready": - styledStatus = style.Bold.Render("ready") + styledStatus = style.Success.Render("ready") case "in_progress": - styledStatus = style.Bold.Render("in_progress") + styledStatus = style.Warning.Render("active") case "blocked": styledStatus = style.Dim.Render("blocked") case "closed": @@ -144,13 +149,13 @@ func runMQList(cmd *cobra.Command, args []string) error { worker = fields.Worker } - // Truncate branch if too long - if len(branch) > 30 { - branch = branch[:27] + "..." - } - - // Format priority + // Format priority with color priority := fmt.Sprintf("P%d", issue.Priority) + if issue.Priority <= 1 { + priority = style.Error.Render(priority) + } else if issue.Priority == 2 { + priority = style.Warning.Render(priority) + } // Calculate age age := formatMRAge(issue.CreatedAt) @@ -161,12 +166,24 @@ func runMQList(cmd *cobra.Command, args []string) error { displayID = displayID[:12] } - fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", - displayID, styledStatus, priority, branch, worker, style.Dim.Render(age)) + table.AddRow(displayID, styledStatus, priority, branch, worker, style.Dim.Render(age)) + } - // Show blocking info if blocked + fmt.Print(table.Render()) + + // Show blocking details below table + for _, issue := range filtered { + displayStatus := issue.Status + if issue.Status == "open" && (len(issue.BlockedBy) > 0 || issue.BlockedByCount > 0) { + displayStatus = "blocked" + } if displayStatus == "blocked" && len(issue.BlockedBy) > 0 { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf(" (waiting on %s)", issue.BlockedBy[0]))) + displayID := issue.ID + if len(displayID) > 12 { + displayID = displayID[:12] + } + fmt.Printf(" %s %s\n", style.Dim.Render(displayID+":"), + style.Dim.Render(fmt.Sprintf("waiting on %s", issue.BlockedBy[0]))) } } diff --git a/internal/cmd/session.go b/internal/cmd/session.go index 9c5b77b2..654e5c78 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -15,6 +15,7 @@ import ( "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/suggest" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) @@ -212,7 +213,9 @@ func runSessionStart(cmd *cobra.Command, args []string) error { } } if !found { - return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) + suggestions := suggest.FindSimilar(polecatName, r.Polecats, 3) + hint := fmt.Sprintf("Create with: gt polecat add %s/%s", rigName, polecatName) + return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint)) } opts := session.StartOptions{ diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 2c768c93..a44c5937 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -19,6 +19,7 @@ import ( "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/suggest" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) @@ -43,21 +44,39 @@ Based on the Universal Gas Town Propulsion Principle: "If you find something on your hook, YOU RUN IT." -Arguments: - thing What to sling: proto name, issue ID, or epic ID - target Who to sling at: agent address (polecat/name, deacon/, etc.) +SLING MECHANICS: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ THING │─────▢│ SLING PIPELINE β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + proto β”‚ 1. SPAWN Proto β†’ Molecule instance β”‚ + issue β”‚ 2. ASSIGN Molecule β†’ Target agent β”‚ + epic β”‚ 3. PIN Work β†’ Agent's hook β”‚ + β”‚ 4. IGNITE Session starts automatically β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ πŸͺ TARGET's HOOK β”‚ β”‚ + β”‚ β”‚ └── [work lands here] β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + Agent runs the work! + +THING TYPES: + proto Molecule template name (e.g., "feature", "bugfix") + issue Beads issue ID (e.g., "gt-abc123") + epic Epic ID for batch dispatch + +TARGET FORMATS: + gastown/Toast β†’ Polecat in rig + gastown/witness β†’ Rig's Witness + gastown/refinery β†’ Rig's Refinery + deacon/ β†’ Global Deacon Examples: - gt sling feature polecat/alpha # Spawn feature mol, sling to alpha - gt sling gt-xyz polecat/beta -m bugfix # Sling issue with bugfix workflow - gt sling patrol deacon/ --wisp # Ephemeral patrol wisp - gt sling gt-epic-batch refinery/ # Batch work to refinery - -What Happens When You Sling: - 1. SPAWN (if proto) - Create molecule from template - 2. ASSIGN - Assign molecule/issue to target agent - 3. PIN - Put work on agent's hook (pinned bead) - 4. IGNITION - Agent wakes and runs the work`, + gt sling feature gastown/Toast # Spawn feature, sling to Toast + gt sling gt-abc gastown/Nux -m bugfix # Issue with workflow + gt sling patrol deacon/ --wisp # Ephemeral patrol wisp`, Args: cobra.ExactArgs(2), RunE: runSling, } @@ -361,7 +380,10 @@ func slingToPolecat(townRoot string, target *SlingTarget, thing *SlingThing) err fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("βœ“")) } else if err == polecat.ErrPolecatNotFound { if !slingCreate { - return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) + suggestions := suggest.FindSimilar(polecatName, r.Polecats, 3) + hint := fmt.Sprintf("Or use --create to create: gt sling %s %s/%s --create", + thing.ID, target.Rig, polecatName) + return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint)) } fmt.Printf("Creating polecat %s...\n", polecatName) if _, err = polecatMgr.Add(polecatName); err != nil { diff --git a/internal/cmd/swarm.go b/internal/cmd/swarm.go index 7bf38d45..2331ba56 100644 --- a/internal/cmd/swarm.go +++ b/internal/cmd/swarm.go @@ -39,7 +39,42 @@ var swarmCmd = &cobra.Command{ Long: `Manage coordinated multi-agent work units (swarms). A swarm coordinates multiple polecats working on related tasks from a shared -base commit. Work is merged to an integration branch, then landed to main.`, +base commit. Work is merged to an integration branch, then landed to main. + +SWARM LIFECYCLE: + epic (tasks) + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SWARM β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Polecat β”‚ β”‚ Polecat β”‚ β”‚ Polecat β”‚ β”‚ + β”‚ β”‚ Toast β”‚ β”‚ Nux β”‚ β”‚ Capable β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β–Ό β–Ό β–Ό β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ integration/ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό land + main + +STATES: + creating β†’ Swarm being set up + active β†’ Workers executing tasks + merging β†’ Work being integrated + landed β†’ Successfully merged to main + cancelled β†’ Swarm aborted + +COMMANDS: + create Create a new swarm from an epic + status Show swarm progress + list List swarms in a rig + land Manually land completed swarm + cancel Cancel an active swarm + dispatch Assign next ready task to a worker`, } var swarmCreateCmd = &cobra.Command{ diff --git a/internal/style/table.go b/internal/style/table.go new file mode 100644 index 00000000..aa543b41 --- /dev/null +++ b/internal/style/table.go @@ -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) +} diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go new file mode 100644 index 00000000..07f58f4a --- /dev/null +++ b/internal/suggest/suggest.go @@ -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() +} diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go new file mode 100644 index 00000000..8a1413e4 --- /dev/null +++ b/internal/suggest/suggest_test.go @@ -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") + } +}