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:
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -447,6 +447,9 @@ var listCmd = &cobra.Command{
|
||||
assigneeStr, labelsStr, issue.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Show tip after successful list (direct mode only)
|
||||
maybeShowTip(store)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
154
cmd/bd/tips.go
Normal 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)
|
||||
}
|
||||
99
cmd/bd/tips_example_test.go
Normal file
99
cmd/bd/tips_example_test.go
Normal 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
274
cmd/bd/tips_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user