Add tip system infrastructure (bd-d4i)

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>
This commit is contained in:
Steve Yegge
2025-11-24 10:56:14 -08:00
parent 22ba6ae4a7
commit 5f310bc7c2
8 changed files with 542 additions and 1 deletions

View File

@@ -395,6 +395,9 @@ var createCmd = &cobra.Command{
fmt.Printf(" Title: %s\n", issue.Title)
fmt.Printf(" Priority: P%d\n", issue.Priority)
fmt.Printf(" Status: %s\n", issue.Status)
// Show tip after successful create (direct mode only)
maybeShowTip(store)
}
},
}

View File

@@ -447,6 +447,9 @@ var listCmd = &cobra.Command{
assigneeStr, labelsStr, issue.Title)
}
}
// Show tip after successful list (direct mode only)
maybeShowTip(store)
},
}

View File

@@ -142,6 +142,8 @@ var readyCmd = &cobra.Command{
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
yellow("✨"))
// Show tip even when no ready work found
maybeShowTip(store)
return
}
cyan := color.New(color.FgCyan).SprintFunc()
@@ -156,6 +158,9 @@ var readyCmd = &cobra.Command{
}
}
fmt.Println()
// Show tip after successful ready (direct mode only)
maybeShowTip(store)
},
}
var blockedCmd = &cobra.Command{

View File

@@ -335,6 +335,9 @@ var showCmd = &cobra.Command{
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
} else if len(allDetails) > 0 {
// Show tip after successful show (non-JSON mode)
maybeShowTip(store)
}
},
}

154
cmd/bd/tips.go Normal file
View File

@@ -0,0 +1,154 @@
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)
}

View File

@@ -0,0 +1,99 @@
package main
import (
"testing"
"time"
)
// This file demonstrates example tip definitions for documentation purposes
func TestExampleTipDefinitions(t *testing.T) {
// Example 1: High priority, high probability tip
// Shows frequently when condition is met
highPriorityTip := Tip{
ID: "example_high_priority",
Condition: func() bool { return true },
Message: "This is an important tip that shows often",
Frequency: 24 * time.Hour, // Show at most once per day
Priority: 100, // Highest priority
Probability: 0.8, // 80% chance when eligible
}
// Example 2: Medium priority, medium probability tip
// General feature discovery
mediumPriorityTip := Tip{
ID: "example_medium_priority",
Condition: func() bool { return true },
Message: "Try using 'bd ready' to see available work",
Frequency: 7 * 24 * time.Hour, // Show at most once per week
Priority: 50, // Medium priority
Probability: 0.5, // 50% chance when eligible
}
// Example 3: Low priority, low probability tip
// Nice-to-know information
lowPriorityTip := Tip{
ID: "example_low_priority",
Condition: func() bool { return true },
Message: "You can filter issues by label with --label flag",
Frequency: 30 * 24 * time.Hour, // Show at most once per month
Priority: 10, // Low priority
Probability: 0.2, // 20% chance when eligible
}
// Example 4: Conditional tip
// Only shows when specific condition is true
conditionalTip := Tip{
ID: "example_conditional",
Condition: func() bool {
// Example: Only show if some condition is met
// In real usage, this might check for specific state
return false // Disabled for this example
},
Message: "This tip only shows when condition is met",
Frequency: 24 * time.Hour,
Priority: 80,
Probability: 0.6,
}
// Verify tips are properly structured (basic validation)
tips := []Tip{highPriorityTip, mediumPriorityTip, lowPriorityTip, conditionalTip}
for _, tip := range tips {
if tip.ID == "" {
t.Error("Tip ID should not be empty")
}
if tip.Message == "" {
t.Error("Tip message should not be empty")
}
if tip.Condition == nil {
t.Error("Tip condition function should not be nil")
}
if tip.Frequency < 0 {
t.Error("Tip frequency should not be negative")
}
if tip.Probability < 0 || tip.Probability > 1 {
t.Errorf("Tip probability should be between 0 and 1, got %f", tip.Probability)
}
}
}
// Example showing probability guidelines
func TestProbabilityGuidelines(t *testing.T) {
examples := []struct {
name string
probability float64
useCase string
}{
{"Critical", 1.0, "Security alerts, breaking changes"},
{"High", 0.8, "Important updates, major features"},
{"Medium", 0.5, "General tips, workflow improvements"},
{"Low", 0.2, "Nice-to-know, advanced features"},
}
for _, ex := range examples {
if ex.probability < 0 || ex.probability > 1 {
t.Errorf("%s: probability %f out of range", ex.name, ex.probability)
}
}
}

274
cmd/bd/tips_test.go Normal file
View File

@@ -0,0 +1,274 @@
package main
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/memory"
)
func TestTipSelection(t *testing.T) {
// Set deterministic seed for testing
os.Setenv("BEADS_TIP_SEED", "12345")
defer os.Unsetenv("BEADS_TIP_SEED")
// Reset RNG
tipRandOnce = sync.Once{}
initTipRand()
// Reset tip registry for testing
tipsMutex.Lock()
tips = []Tip{}
tipsMutex.Unlock()
store := memory.New("")
// Test 1: No tips registered
tip := selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil with no tips registered, got %v", tip)
}
// Test 2: Single tip with condition = true
tipsMutex.Lock()
tips = append(tips, Tip{
ID: "test_tip_1",
Condition: func() bool { return true },
Message: "Test tip 1",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0, // Always show
})
tipsMutex.Unlock()
tip = selectNextTip(store)
if tip == nil {
t.Fatal("Expected tip to be selected")
}
if tip.ID != "test_tip_1" {
t.Errorf("Expected tip ID 'test_tip_1', got %q", tip.ID)
}
// Test 3: Frequency limit - should not show again immediately
recordTipShown(store, "test_tip_1")
tip = selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil due to frequency limit, got %v", tip)
}
// Test 4: Multiple tips - priority order
tipsMutex.Lock()
tips = []Tip{
{
ID: "low_priority",
Condition: func() bool { return true },
Message: "Low priority tip",
Frequency: 1 * time.Hour,
Priority: 10,
Probability: 1.0,
},
{
ID: "high_priority",
Condition: func() bool { return true },
Message: "High priority tip",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
tip = selectNextTip(store)
if tip == nil {
t.Fatal("Expected tip to be selected")
}
if tip.ID != "high_priority" {
t.Errorf("Expected high_priority tip to be selected first, got %q", tip.ID)
}
// Test 5: Condition = false
tipsMutex.Lock()
tips = []Tip{
{
ID: "never_show",
Condition: func() bool { return false },
Message: "Never shown",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
tip = selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil due to condition=false, got %v", tip)
}
}
func TestTipProbability(t *testing.T) {
// Set deterministic seed
os.Setenv("BEADS_TIP_SEED", "99999")
defer os.Unsetenv("BEADS_TIP_SEED")
// Reset RNG by creating a new Once
tipRandOnce = sync.Once{}
initTipRand()
tipsMutex.Lock()
tips = []Tip{
{
ID: "rare_tip",
Condition: func() bool { return true },
Message: "Rare tip",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 0.01, // 1% chance
},
}
tipsMutex.Unlock()
store := memory.New("")
// Run selection multiple times
shownCount := 0
for i := 0; i < 100; i++ {
// Clear last shown timestamp to make tip eligible
_ = store.SetMetadata(context.Background(), "tip_rare_tip_last_shown", "")
tip := selectNextTip(store)
if tip != nil {
shownCount++
}
}
// With 1% probability, we expect ~1 show out of 100
// Allow some variance (0-10 is reasonable for low probability)
if shownCount > 10 {
t.Errorf("Expected ~1 tip shown with 1%% probability, got %d", shownCount)
}
}
func TestGetLastShown(t *testing.T) {
store := memory.New("")
// Test 1: Never shown
lastShown := getLastShown(store, "never_shown")
if !lastShown.IsZero() {
t.Errorf("Expected zero time for never shown tip, got %v", lastShown)
}
// Test 2: Recently shown
now := time.Now()
_ = store.SetMetadata(context.Background(), "tip_test_last_shown", now.Format(time.RFC3339))
lastShown = getLastShown(store, "test")
if lastShown.IsZero() {
t.Error("Expected non-zero time for shown tip")
}
// Should be within 1 second (accounting for rounding)
diff := now.Sub(lastShown)
if diff < 0 {
diff = -diff
}
if diff > time.Second {
t.Errorf("Expected last shown time to be close to now, got diff %v", diff)
}
}
func TestRecordTipShown(t *testing.T) {
store := memory.New("")
recordTipShown(store, "test_tip")
// Verify it was recorded
lastShown := getLastShown(store, "test_tip")
if lastShown.IsZero() {
t.Error("Expected tip to be recorded as shown")
}
// Should be very recent
if time.Since(lastShown) > time.Second {
t.Errorf("Expected recent timestamp, got %v", lastShown)
}
}
func TestMaybeShowTip_RespectsFlags(t *testing.T) {
// Set deterministic seed
os.Setenv("BEADS_TIP_SEED", "54321")
defer os.Unsetenv("BEADS_TIP_SEED")
tipsMutex.Lock()
tips = []Tip{
{
ID: "always_show",
Condition: func() bool { return true },
Message: "Always show tip",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
store := memory.New("")
// Test 1: Should not show in JSON mode
jsonOutput = true
maybeShowTip(store) // Should not panic or show output
jsonOutput = false
// Test 2: Should not show in quiet mode
quietFlag = true
maybeShowTip(store) // Should not panic or show output
quietFlag = false
// Test 3: Should show in normal mode (no assertions, just testing it doesn't panic)
maybeShowTip(store)
}
func TestTipFrequency(t *testing.T) {
store := memory.New("")
tipsMutex.Lock()
tips = []Tip{
{
ID: "frequent_tip",
Condition: func() bool { return true },
Message: "Frequent tip",
Frequency: 5 * time.Second,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
// First selection should work
tip := selectNextTip(store)
if tip == nil {
t.Fatal("Expected tip to be selected")
}
// Record it as shown
recordTipShown(store, tip.ID)
// Should not show again immediately (within frequency window)
tip = selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil due to frequency limit, got %v", tip)
}
// Manually set last shown to past (simulate time passing)
past := time.Now().Add(-10 * time.Second)
_ = store.SetMetadata(context.Background(), "tip_frequent_tip_last_shown", past.Format(time.RFC3339))
// Should show again now
tip = selectNextTip(store)
if tip == nil {
t.Error("Expected tip to be selected after frequency window passed")
}
}