feat: auto-detect non-TTY and adjust output (bd-xrwy)
Add TTY detection to automatically disable ANSI colors when stdout is piped or redirected. Respects standard conventions: - NO_COLOR environment variable (no-color.org) - CLICOLOR=0 disables color - CLICOLOR_FORCE enables color even in non-TTY Also adds ShouldUseEmoji() helper controlled by BD_NO_EMOJI. Pager already disabled non-TTY in previous work (bd-jdz3). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,16 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
|
||||
if !ShouldUseColor() {
|
||||
lipgloss.SetColorProfile(termenv.Ascii)
|
||||
}
|
||||
}
|
||||
|
||||
// Ayu theme color palette
|
||||
// Dark: https://terminalcolors.com/themes/ayu/dark/
|
||||
// Light: https://terminalcolors.com/themes/ayu/light/
|
||||
|
||||
52
internal/ui/terminal.go
Normal file
52
internal/ui/terminal.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package ui provides terminal styling and output helpers for beads CLI.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IsTerminal returns true if stdout is connected to a terminal (TTY).
|
||||
func IsTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// ShouldUseColor determines if ANSI color codes should be used.
|
||||
// Respects standard conventions:
|
||||
// - NO_COLOR: https://no-color.org/ - disables color if set
|
||||
// - CLICOLOR=0: disables color
|
||||
// - CLICOLOR_FORCE: forces color even in non-TTY
|
||||
// - Falls back to TTY detection
|
||||
func ShouldUseColor() bool {
|
||||
// NO_COLOR standard - any value disables color
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// CLICOLOR=0 disables color
|
||||
if os.Getenv("CLICOLOR") == "0" {
|
||||
return false
|
||||
}
|
||||
|
||||
// CLICOLOR_FORCE forces color even in non-TTY
|
||||
if os.Getenv("CLICOLOR_FORCE") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default: use color only if stdout is a TTY
|
||||
return IsTerminal()
|
||||
}
|
||||
|
||||
// ShouldUseEmoji determines if emoji decorations should be used.
|
||||
// Disabled in non-TTY mode to keep output machine-readable.
|
||||
// Can be controlled with BD_NO_EMOJI environment variable.
|
||||
func ShouldUseEmoji() bool {
|
||||
// Explicit disable
|
||||
if os.Getenv("BD_NO_EMOJI") != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Default: use emoji only if stdout is a TTY
|
||||
return IsTerminal()
|
||||
}
|
||||
139
internal/ui/terminal_test.go
Normal file
139
internal/ui/terminal_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldUseColor(t *testing.T) {
|
||||
// Save original env vars
|
||||
origNoColor := os.Getenv("NO_COLOR")
|
||||
origCliColor := os.Getenv("CLICOLOR")
|
||||
origCliColorForce := os.Getenv("CLICOLOR_FORCE")
|
||||
defer func() {
|
||||
setEnv("NO_COLOR", origNoColor)
|
||||
setEnv("CLICOLOR", origCliColor)
|
||||
setEnv("CLICOLOR_FORCE", origCliColorForce)
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor string
|
||||
cliColor string
|
||||
cliColorForce string
|
||||
wantColor bool
|
||||
skipTTYDepCheck bool // Some tests don't depend on TTY state
|
||||
}{
|
||||
{
|
||||
name: "NO_COLOR disables color",
|
||||
noColor: "1",
|
||||
wantColor: false,
|
||||
skipTTYDepCheck: true,
|
||||
},
|
||||
{
|
||||
name: "NO_COLOR empty string value still disables",
|
||||
noColor: "", // will be unset
|
||||
cliColor: "",
|
||||
cliColorForce: "",
|
||||
wantColor: false, // depends on TTY, but we're in test = no TTY
|
||||
skipTTYDepCheck: false,
|
||||
},
|
||||
{
|
||||
name: "CLICOLOR=0 disables color",
|
||||
cliColor: "0",
|
||||
wantColor: false,
|
||||
skipTTYDepCheck: true,
|
||||
},
|
||||
{
|
||||
name: "CLICOLOR_FORCE enables color even in non-TTY",
|
||||
cliColorForce: "1",
|
||||
wantColor: true,
|
||||
skipTTYDepCheck: true,
|
||||
},
|
||||
{
|
||||
name: "NO_COLOR takes precedence over CLICOLOR_FORCE",
|
||||
noColor: "1",
|
||||
cliColorForce: "1",
|
||||
wantColor: false,
|
||||
skipTTYDepCheck: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear all vars first
|
||||
os.Unsetenv("NO_COLOR")
|
||||
os.Unsetenv("CLICOLOR")
|
||||
os.Unsetenv("CLICOLOR_FORCE")
|
||||
|
||||
// Set test-specific vars
|
||||
if tt.noColor != "" {
|
||||
os.Setenv("NO_COLOR", tt.noColor)
|
||||
}
|
||||
if tt.cliColor != "" {
|
||||
os.Setenv("CLICOLOR", tt.cliColor)
|
||||
}
|
||||
if tt.cliColorForce != "" {
|
||||
os.Setenv("CLICOLOR_FORCE", tt.cliColorForce)
|
||||
}
|
||||
|
||||
got := ShouldUseColor()
|
||||
if tt.skipTTYDepCheck && got != tt.wantColor {
|
||||
t.Errorf("ShouldUseColor() = %v, want %v", got, tt.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseEmoji(t *testing.T) {
|
||||
// Save original env var
|
||||
origNoEmoji := os.Getenv("BD_NO_EMOJI")
|
||||
defer setEnv("BD_NO_EMOJI", origNoEmoji)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
noEmoji string
|
||||
wantEmoji bool
|
||||
}{
|
||||
{
|
||||
name: "BD_NO_EMOJI disables emoji",
|
||||
noEmoji: "1",
|
||||
wantEmoji: false,
|
||||
},
|
||||
{
|
||||
name: "No BD_NO_EMOJI falls back to TTY check",
|
||||
noEmoji: "",
|
||||
wantEmoji: false, // In test, stdout is not a TTY
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
os.Unsetenv("BD_NO_EMOJI")
|
||||
if tt.noEmoji != "" {
|
||||
os.Setenv("BD_NO_EMOJI", tt.noEmoji)
|
||||
}
|
||||
|
||||
got := ShouldUseEmoji()
|
||||
if got != tt.wantEmoji {
|
||||
t.Errorf("ShouldUseEmoji() = %v, want %v", got, tt.wantEmoji)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminal(t *testing.T) {
|
||||
// When running under go test, stdout is typically not a TTY
|
||||
got := IsTerminal()
|
||||
// We can't easily assert the value, but we can verify it doesn't panic
|
||||
t.Logf("IsTerminal() = %v (expected false in test environment)", got)
|
||||
}
|
||||
|
||||
// setEnv sets or unsets an environment variable
|
||||
func setEnv(key, value string) {
|
||||
if value == "" {
|
||||
os.Unsetenv(key)
|
||||
} else {
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user