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:
@@ -43,7 +43,38 @@ var mailCmd = &cobra.Command{
|
|||||||
Long: `Send and receive messages between agents.
|
Long: `Send and receive messages between agents.
|
||||||
|
|
||||||
The mail system allows Mayor, polecats, and the Refinery to communicate.
|
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
|
||||||
|
<rig>/witness → Rig's Witness
|
||||||
|
<rig>/refinery → Rig's Refinery
|
||||||
|
<rig>/<polecat> → Polecat (e.g., gastown/Toast)
|
||||||
|
<rig>/crew/<name> → 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{
|
var mailSendCmd = &cobra.Command{
|
||||||
|
|||||||
@@ -30,7 +30,39 @@ var moleculeCmd = &cobra.Command{
|
|||||||
Long: `Manage molecule workflow templates.
|
Long: `Manage molecule workflow templates.
|
||||||
|
|
||||||
Molecules are composable workflow patterns stored as beads issues.
|
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{
|
var moleculeListCmd = &cobra.Command{
|
||||||
@@ -386,23 +418,35 @@ func runMoleculeList(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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 {
|
for _, mol := range entries {
|
||||||
sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source))
|
// Format steps count
|
||||||
|
stepStr := ""
|
||||||
stepCount := ""
|
|
||||||
if mol.StepCount > 0 {
|
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" {
|
if mol.Status == "closed" {
|
||||||
statusMarker = " " + style.Dim.Render("[closed]")
|
title = style.Dim.Render(mol.Title + " [closed]")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" %s: %s%s%s %s\n",
|
// Format source
|
||||||
style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker, sourceMarker)
|
source := style.Dim.Render(mol.Source)
|
||||||
|
|
||||||
|
table.AddRow(mol.ID, title, stepStr, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Print(table.Render())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,12 +104,17 @@ func runMQList(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print header
|
// Create styled table
|
||||||
fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n",
|
table := style.NewTable(
|
||||||
"ID", "STATUS", "PRIORITY", "BRANCH", "WORKER", "AGE")
|
style.Column{Name: "ID", Width: 12},
|
||||||
fmt.Printf(" %s\n", strings.Repeat("-", 90))
|
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 {
|
for _, issue := range filtered {
|
||||||
fields := beads.ParseMRFields(issue)
|
fields := beads.ParseMRFields(issue)
|
||||||
|
|
||||||
@@ -127,9 +132,9 @@ func runMQList(cmd *cobra.Command, args []string) error {
|
|||||||
styledStatus := displayStatus
|
styledStatus := displayStatus
|
||||||
switch displayStatus {
|
switch displayStatus {
|
||||||
case "ready":
|
case "ready":
|
||||||
styledStatus = style.Bold.Render("ready")
|
styledStatus = style.Success.Render("ready")
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
styledStatus = style.Bold.Render("in_progress")
|
styledStatus = style.Warning.Render("active")
|
||||||
case "blocked":
|
case "blocked":
|
||||||
styledStatus = style.Dim.Render("blocked")
|
styledStatus = style.Dim.Render("blocked")
|
||||||
case "closed":
|
case "closed":
|
||||||
@@ -144,13 +149,13 @@ func runMQList(cmd *cobra.Command, args []string) error {
|
|||||||
worker = fields.Worker
|
worker = fields.Worker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate branch if too long
|
// Format priority with color
|
||||||
if len(branch) > 30 {
|
|
||||||
branch = branch[:27] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format priority
|
|
||||||
priority := fmt.Sprintf("P%d", issue.Priority)
|
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
|
// Calculate age
|
||||||
age := formatMRAge(issue.CreatedAt)
|
age := formatMRAge(issue.CreatedAt)
|
||||||
@@ -161,12 +166,24 @@ func runMQList(cmd *cobra.Command, args []string) error {
|
|||||||
displayID = displayID[:12]
|
displayID = displayID[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n",
|
table.AddRow(displayID, styledStatus, priority, branch, worker, style.Dim.Render(age))
|
||||||
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 {
|
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])))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/suggest"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -212,7 +213,9 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
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{
|
opts := session.StartOptions{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/suggest"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"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."
|
"If you find something on your hook, YOU RUN IT."
|
||||||
|
|
||||||
Arguments:
|
SLING MECHANICS:
|
||||||
thing What to sling: proto name, issue ID, or epic ID
|
┌─────────┐ ┌───────────────────────────────────────────┐
|
||||||
target Who to sling at: agent address (polecat/name, deacon/, etc.)
|
│ 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:
|
Examples:
|
||||||
gt sling feature polecat/alpha # Spawn feature mol, sling to alpha
|
gt sling feature gastown/Toast # Spawn feature, sling to Toast
|
||||||
gt sling gt-xyz polecat/beta -m bugfix # Sling issue with bugfix workflow
|
gt sling gt-abc gastown/Nux -m bugfix # Issue with workflow
|
||||||
gt sling patrol deacon/ --wisp # Ephemeral patrol wisp
|
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`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: runSling,
|
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("✓"))
|
fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓"))
|
||||||
} else if err == polecat.ErrPolecatNotFound {
|
} else if err == polecat.ErrPolecatNotFound {
|
||||||
if !slingCreate {
|
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)
|
fmt.Printf("Creating polecat %s...\n", polecatName)
|
||||||
if _, err = polecatMgr.Add(polecatName); err != nil {
|
if _, err = polecatMgr.Add(polecatName); err != nil {
|
||||||
|
|||||||
@@ -39,7 +39,42 @@ var swarmCmd = &cobra.Command{
|
|||||||
Long: `Manage coordinated multi-agent work units (swarms).
|
Long: `Manage coordinated multi-agent work units (swarms).
|
||||||
|
|
||||||
A swarm coordinates multiple polecats working on related tasks from a shared
|
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/<epic> │ │
|
||||||
|
│ └───────────────────┬──────────────────┘ │
|
||||||
|
└──────────────────────┼────────────────────┘
|
||||||
|
│
|
||||||
|
▼ 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{
|
var swarmCreateCmd = &cobra.Command{
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
232
internal/suggest/suggest.go
Normal file
232
internal/suggest/suggest.go
Normal 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()
|
||||||
|
}
|
||||||
120
internal/suggest/suggest_test.go
Normal file
120
internal/suggest/suggest_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user