Implements a smart contextual hint system that shows helpful messages to users after successful commands. Tips are filtered by conditions, priority, frequency limits, and probability rolls to provide useful information without being annoying. Core Features: - Tip struct with condition, message, frequency, priority, probability - selectNextTip() filters eligible tips and applies probability - Metadata storage tracks when tips were last shown - Respects --json and --quiet flags - Deterministic testing via BEADS_TIP_SEED env var Integration Points: - bd list: Shows tip after listing issues - bd ready: Shows tip after showing ready work (or no work) - bd create: Shows tip after creating issue - bd show: Shows tip after showing issue details Testing: - Unit tests for tip selection logic - Tests for frequency limits and probability - Tests for metadata tracking - Example tip definitions for documentation Next Steps: - bd-81a: Add programmatic tip injection API - bd-tne: Add Claude setup tip with dynamic priority 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
155 lines
4.0 KiB
Go
155 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
)
|
|
|
|
// Tip represents a contextual hint that can be shown to users after successful commands
|
|
type Tip struct {
|
|
ID string
|
|
Condition func() bool // Should this tip be eligible?
|
|
Message string // The tip message to display
|
|
Frequency time.Duration // Minimum gap between showings
|
|
Priority int // Higher = shown first when eligible
|
|
Probability float64 // 0.0 to 1.0 - chance of showing when eligible
|
|
}
|
|
|
|
var (
|
|
// tips is the registry of all available tips
|
|
tips []Tip
|
|
|
|
// tipsMutex protects the tips registry for thread-safe access
|
|
tipsMutex sync.RWMutex
|
|
|
|
// tipRand is the random number generator for probability rolls
|
|
// Can be seeded deterministically via BEADS_TIP_SEED for testing
|
|
tipRand *rand.Rand
|
|
|
|
// tipRandOnce ensures we only initialize the RNG once
|
|
tipRandOnce sync.Once
|
|
)
|
|
|
|
// initTipRand initializes the random number generator for tip selection
|
|
// Uses BEADS_TIP_SEED env var for deterministic testing if set
|
|
func initTipRand() {
|
|
tipRandOnce.Do(func() {
|
|
seed := time.Now().UnixNano()
|
|
if seedStr := os.Getenv("BEADS_TIP_SEED"); seedStr != "" {
|
|
if parsedSeed, err := strconv.ParseInt(seedStr, 10, 64); err == nil {
|
|
seed = parsedSeed
|
|
}
|
|
}
|
|
// Use deprecated rand.NewSource for Go 1.19 compatibility
|
|
// nolint:gosec,staticcheck // G404: deterministic seed via env var is intentional for testing
|
|
tipRand = rand.New(rand.NewSource(seed))
|
|
})
|
|
}
|
|
|
|
// maybeShowTip selects and displays an eligible tip based on priority and probability
|
|
// Respects --json and --quiet flags
|
|
func maybeShowTip(store storage.Storage) {
|
|
// Skip tips in JSON output mode or quiet mode
|
|
if jsonOutput || quietFlag {
|
|
return
|
|
}
|
|
|
|
// Initialize RNG if needed
|
|
initTipRand()
|
|
|
|
// Select next tip
|
|
tip := selectNextTip(store)
|
|
if tip == nil {
|
|
return
|
|
}
|
|
|
|
// Display tip to stdout (informational, not an error)
|
|
fmt.Fprintf(os.Stdout, "\n💡 Tip: %s\n", tip.Message)
|
|
|
|
// Record that we showed this tip
|
|
recordTipShown(store, tip.ID)
|
|
}
|
|
|
|
// selectNextTip finds the next tip to show based on conditions, frequency, priority, and probability
|
|
// Returns nil if no tip should be shown
|
|
func selectNextTip(store storage.Storage) *Tip {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
var eligibleTips []Tip
|
|
|
|
// Lock for reading the tip registry
|
|
tipsMutex.RLock()
|
|
defer tipsMutex.RUnlock()
|
|
|
|
// Filter to eligible tips (condition + frequency check)
|
|
for _, tip := range tips {
|
|
// Check if tip's condition is met
|
|
if !tip.Condition() {
|
|
continue
|
|
}
|
|
|
|
// Check if enough time has passed since last showing
|
|
lastShown := getLastShown(store, tip.ID)
|
|
if !lastShown.IsZero() && now.Sub(lastShown) < tip.Frequency {
|
|
continue
|
|
}
|
|
|
|
eligibleTips = append(eligibleTips, tip)
|
|
}
|
|
|
|
if len(eligibleTips) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Sort by priority (highest first)
|
|
sort.Slice(eligibleTips, func(i, j int) bool {
|
|
return eligibleTips[i].Priority > eligibleTips[j].Priority
|
|
})
|
|
|
|
// Apply probability roll (in priority order)
|
|
// Higher priority tips get first chance to show
|
|
for i := range eligibleTips {
|
|
if tipRand.Float64() < eligibleTips[i].Probability {
|
|
return &eligibleTips[i]
|
|
}
|
|
}
|
|
|
|
return nil // No tips won probability roll
|
|
}
|
|
|
|
// getLastShown retrieves the timestamp when a tip was last shown
|
|
// Returns zero time if never shown
|
|
func getLastShown(store storage.Storage, tipID string) time.Time {
|
|
key := fmt.Sprintf("tip_%s_last_shown", tipID)
|
|
value, err := store.GetMetadata(context.Background(), key)
|
|
if err != nil || value == "" {
|
|
return time.Time{}
|
|
}
|
|
|
|
// Parse RFC3339 timestamp
|
|
t, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// recordTipShown records the timestamp when a tip was shown
|
|
func recordTipShown(store storage.Storage, tipID string) {
|
|
key := fmt.Sprintf("tip_%s_last_shown", tipID)
|
|
value := time.Now().Format(time.RFC3339)
|
|
_ = store.SetMetadata(context.Background(), key, value)
|
|
}
|