Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -1760,6 +1760,12 @@ func runInteractiveModeBubbleTea(_ context.Context, deps runModeDeps) error {
MCPResourceReader: mcpResourceReader,
})

// Resolve terminal capabilities (background, colour profile) now, before
// the TUI takes over stdin. The detection runs a synchronous OSC query, so
// it must happen here rather than lazily mid-render where it would race the
// event loop. No-op if a theme in config already resolved them.
ui.ResolveTerminalCapabilities()

program := tea.NewProgram(appModel)

// Register the program with the app layer so agent events are sent to the TUI.
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/exports.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,10 @@ var (
AdaptiveColor = style.AdaptiveColor
IsDarkBackground = style.IsDarkBackground
SyntaxStyle = style.SyntaxStyle

// SetTerminalCapabilities and ResolveTerminalCapabilities let a frontend
// control per-terminal styling inputs instead of relying on a process-global
// probe. See internal/ui/style.
SetTerminalCapabilities = style.SetTerminalCapabilities
ResolveTerminalCapabilities = style.ResolveTerminalCapabilities
)
153 changes: 139 additions & 14 deletions internal/ui/style/enhanced.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math"
"os"
"strings"
"sync"

"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2"
Expand All @@ -16,11 +17,107 @@ import (

// Enhanced styling utilities and theme definitions

// isDarkBg caches the terminal background detection result at package init.
var isDarkBg = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
// terminalCapabilities captures the per-terminal styling inputs: whether the
// background is dark (drives adaptive colour selection) and the terminal's
// colour depth (drives whether smooth animation is worthwhile).
type terminalCapabilities struct {
darkBackground bool
colorProfile colorprofile.Profile
}

// termCaps holds the resolved terminal capabilities. It is nil until first use
// or until SetTerminalCapabilities is called, so the (blocking, fd-touching)
// probe never runs at package-init time. Guarded by termCapsMu.
var (
termCapsMu sync.RWMutex
termCaps *terminalCapabilities
)

// resolveTerminalCapabilities probes the current process's terminal. It is the
// default source of capabilities when SetTerminalCapabilities has not been
// called, and is kept separate so it never fires as an import-time side effect.
func resolveTerminalCapabilities() terminalCapabilities {
return terminalCapabilities{
darkBackground: lipgloss.HasDarkBackground(os.Stdin, os.Stdout),
colorProfile: colorprofile.Env(os.Environ()),
}
}

// termColorProfile caches the terminal's colour depth at package init.
var termColorProfile = colorprofile.Env(os.Environ())
// currentTerminalCapabilities returns the active capabilities, resolving them
// lazily against the process terminal the first time they are needed. Callers
// that serve a specific client should call SetTerminalCapabilities first so
// this never falls back to whatever fds the process happens to hold.
func currentTerminalCapabilities() terminalCapabilities {
termCapsMu.RLock()
c := termCaps
termCapsMu.RUnlock()
if c != nil {
return *c
}

termCapsMu.Lock()
defer termCapsMu.Unlock()
if termCaps == nil {
resolved := resolveTerminalCapabilities()
termCaps = &resolved
}
return *termCaps
}

// SetTerminalCapabilities overrides the terminal background and colour profile
// used by every subsequent styling decision in this package.
//
// It exists so a frontend can supply the capabilities of the terminal it is
// actually serving instead of inheriting whatever the process's own fds report.
// This replaces the former package-init probes, which fired once against the
// process stdin/stdout and could not represent more than one terminal.
//
// Call this on the UI goroutine, the same as SetTheme: besides the
// mutex-guarded capability swap, it rebuilds theme-derived caches
// (currentTheme, the generation counter, the typography/style/syntax caches)
// that the render loop reads unsynchronized. It is intended to be called once
// during setup, before the render loop starts, or from within Update.
func SetTerminalCapabilities(darkBackground bool, colorProfile colorprofile.Profile) {
termCapsMu.Lock()
termCaps = &terminalCapabilities{
darkBackground: darkBackground,
colorProfile: colorProfile,
}
termCapsMu.Unlock()

// A lazily-derived DefaultTheme baked in the previously-detected
// background, so drop it and let GetTheme rebuild against the new
// capabilities. A theme the caller chose explicitly is left as-is.
if !themeExplicitlySet {
currentTheme = nil
}
invalidateThemeCaches()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// isDarkBackground reports whether the active terminal has a dark background,
// resolving capabilities lazily on first use.
func isDarkBackground() bool {
return currentTerminalCapabilities().darkBackground
}

// ResolveTerminalCapabilities eagerly resolves the terminal background and
// colour profile against the current process terminal, unless they have
// already been resolved or supplied via SetTerminalCapabilities.
//
// The interactive frontend calls this once during startup, before the TUI
// takes over the terminal, so the background-detection OSC query runs at a
// point where it cannot race the event loop's reads of stdin. Headless
// frontends should call SetTerminalCapabilities instead and skip this, to avoid
// probing fds that do not describe their client.
func ResolveTerminalCapabilities() {
_ = currentTerminalCapabilities()
}

// terminalColorProfile returns the active terminal's colour depth, resolving
// capabilities lazily on first use.
func terminalColorProfile() colorprofile.Profile {
return currentTerminalCapabilities().colorProfile
}

// SupportsSmoothAnimation reports whether the terminal has the colour depth
// for a gradient animation to read as motion.
Expand All @@ -30,21 +127,34 @@ var termColorProfile = colorprofile.Env(os.Environ())
// effect. Honouring the profile also means NO_COLOR and non-TTY output opt out
// for free.
func SupportsSmoothAnimation() bool {
return termColorProfile >= colorprofile.ANSI256
return terminalColorProfile() >= colorprofile.ANSI256
}

// AdaptiveColor picks between a light-mode and dark-mode hex color string
// based on the detected terminal background. This replaces the old
// lipgloss.AdaptiveColor{Light: ..., Dark: ...} pattern from v1.
func AdaptiveColor(light, dark string) color.Color {
if isDarkBg {
if isDarkBackground() {
return lipgloss.Color(dark)
}
return lipgloss.Color(light)
}

// Global theme instance
var currentTheme = DefaultTheme()
// currentTheme holds the active UI theme. It is nil until first use so that
// DefaultTheme — which calls AdaptiveColor and would therefore probe the
// terminal — is not evaluated at package-init time. Resolved lazily by
// GetTheme, or set explicitly by SetTheme.
//
// Like themeGeneration below, this and themeExplicitlySet are mutated only on
// the UI goroutine (SetTheme / SetTerminalCapabilities) and read from the same
// Update/View cycle, so they carry no lock of their own.
var currentTheme *Theme

// themeExplicitlySet records whether the active theme came from SetTheme (true)
// or was lazily derived from DefaultTheme (false). A derived theme bakes in the
// terminal background detected when it was built, so SetTerminalCapabilities
// must discard and rebuild it; an explicitly chosen theme is left untouched.
var themeExplicitlySet bool

// themeGeneration counts theme changes. It starts at 1 so that a zero-valued
// generation stamp — the state of any freshly allocated cache — never compares
Expand All @@ -55,9 +165,14 @@ var currentTheme = DefaultTheme()
var themeGeneration uint64 = 1

// GetTheme returns the currently active UI theme. The theme controls all color
// and styling decisions throughout the application's interface.
// and styling decisions throughout the application's interface. On first use it
// lazily derives DefaultTheme against the active terminal capabilities.
func GetTheme() Theme {
return currentTheme
if currentTheme == nil {
derived := DefaultTheme()
currentTheme = &derived
}
return *currentTheme
}

// ThemeGeneration returns a counter that increments on every theme change.
Expand All @@ -76,7 +191,15 @@ func ThemeGeneration() uint64 {
// It also invalidates the markdownTypographyCache so the next call to
// GetMarkdownTypography picks up the new theme.
func SetTheme(theme Theme) {
currentTheme = theme
currentTheme = &theme
themeExplicitlySet = true
invalidateThemeCaches()
}

// invalidateThemeCaches bumps the theme generation and drops every cache whose
// contents depend on the active theme colours, so the next access rebuilds
// them. Shared by SetTheme and SetTerminalCapabilities.
func invalidateThemeCaches() {
themeGeneration++ // invalidate generation-stamped render caches
markdownTypographyCache = nil // invalidate cached renderer; colors may have changed
uiTypographyCache = nil // invalidate cached block typography; colors may have changed
Expand Down Expand Up @@ -266,9 +389,11 @@ func DefaultTheme() Theme {
}
}

// IsDarkBackground returns the cached terminal background detection result.
// IsDarkBackground returns whether the active terminal has a dark background.
// Capabilities are resolved lazily on first use, or supplied ahead of time via
// SetTerminalCapabilities.
func IsDarkBackground() bool {
return isDarkBg
return isDarkBackground()
}

// CreateBadge generates a styled badge or label with inverted colors (text on
Expand Down Expand Up @@ -408,7 +533,7 @@ const (
// subtler there. That is a smaller cost than inverting the gesture.
func logoShineColor() color.Color {
amount := logoShineLightenLight
if isDarkBg {
if isDarkBackground() {
amount = logoShineLightenDark
}
return blendColors(GetTheme().Primary, color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}, amount)
Expand Down
18 changes: 9 additions & 9 deletions internal/ui/style/logo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,11 @@ func relativeLuminance(c color.Color) float64 {
// named for: an earlier contrast-pole rule inverted on pale palettes and swept
// something *darker* across the wordmark, which reads as a smudge.
func TestLogoShineIsAlwaysLighterThanWordmark(t *testing.T) {
origTheme, origDark := GetTheme(), isDarkBg
t.Cleanup(func() { isDarkBg = origDark; SetTheme(origTheme) })
origTheme, origDark, origProfile := GetTheme(), isDarkBackground(), terminalColorProfile()
t.Cleanup(func() { SetTerminalCapabilities(origDark, origProfile); SetTheme(origTheme) })

for _, dark := range []bool{true, false} {
isDarkBg = dark
SetTerminalCapabilities(dark, origProfile)
for name, th := range builtinThemes() {
SetTheme(th)
shine := relativeLuminance(logoShineColor())
Expand All @@ -235,16 +235,16 @@ func TestLogoShineIsAlwaysLighterThanWordmark(t *testing.T) {
// Whatever theme is active, the shine has to be far enough from the wordmark's
// own colours to register as a highlight crossing it.
func TestLogoShineContrastsWithWordmarkInEveryTheme(t *testing.T) {
origTheme, origDark := GetTheme(), isDarkBg
t.Cleanup(func() { isDarkBg = origDark; SetTheme(origTheme) })
origTheme, origDark, origProfile := GetTheme(), isDarkBackground(), terminalColorProfile()
t.Cleanup(func() { SetTerminalCapabilities(origDark, origProfile); SetTheme(origTheme) })

// Floor sits just below the measured worst case (~46, catppuccin on dark
// and vesper on light). Those two palettes have an unusually pale Primary,
// which leaves little headroom above it.
const minDistance = 40

for _, dark := range []bool{true, false} {
isDarkBg = dark
SetTerminalCapabilities(dark, origProfile)
for name, th := range builtinThemes() {
SetTheme(th)
shine := logoShineColor()
Expand All @@ -266,14 +266,14 @@ func TestLogoShineContrastsWithWordmarkInEveryTheme(t *testing.T) {
// lift toward white from simply being turned up: on a light background it
// closes on the page fast.
func TestLogoShineDoesNotCollideWithBackground(t *testing.T) {
origTheme, origDark := GetTheme(), isDarkBg
t.Cleanup(func() { isDarkBg = origDark; SetTheme(origTheme) })
origTheme, origDark, origProfile := GetTheme(), isDarkBackground(), terminalColorProfile()
t.Cleanup(func() { SetTerminalCapabilities(origDark, origProfile); SetTheme(origTheme) })

// Worst measured case is ~53 (vesper on a light background).
const minDistance = 45

for _, dark := range []bool{true, false} {
isDarkBg = dark
SetTerminalCapabilities(dark, origProfile)
for name, th := range builtinThemes() {
SetTheme(th)
if d := distance(logoShineColor(), th.Background); d < minDistance {
Expand Down
93 changes: 93 additions & 0 deletions internal/ui/style/terminal_caps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package style

import (
"testing"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
)

// saveStyleState snapshots the package-global styling state these tests mutate
// and returns a restore function, so a test never leaks capabilities or a theme
// into the ones that follow (this package's tests run non-parallel).
func saveStyleState(t *testing.T) {
t.Helper()
termCapsMu.Lock()
caps := termCaps
termCapsMu.Unlock()
theme := currentTheme
explicit := themeExplicitlySet
gen := themeGeneration

t.Cleanup(func() {
termCapsMu.Lock()
termCaps = caps
termCapsMu.Unlock()
currentTheme = theme
themeExplicitlySet = explicit
themeGeneration = gen
styleCache = nil
markdownTypographyCache = nil
uiTypographyCache = nil
syntaxStyleCache = nil
})
}

func TestSetTerminalCapabilitiesOverridesDetection(t *testing.T) {
saveStyleState(t)

SetTerminalCapabilities(true, colorprofile.TrueColor)
if !IsDarkBackground() {
t.Fatal("IsDarkBackground() = false after setting dark background")
}
if !SupportsSmoothAnimation() {
t.Fatal("SupportsSmoothAnimation() = false for a TrueColor profile")
}

SetTerminalCapabilities(false, colorprofile.Ascii)
if IsDarkBackground() {
t.Fatal("IsDarkBackground() = true after setting light background")
}
if SupportsSmoothAnimation() {
t.Fatal("SupportsSmoothAnimation() = true for an Ascii profile")
}
}

func TestSetTerminalCapabilitiesRebuildsDerivedTheme(t *testing.T) {
saveStyleState(t)

// Force a fresh, lazily-derived theme for a light terminal.
currentTheme = nil
themeExplicitlySet = false
SetTerminalCapabilities(false, colorprofile.TrueColor)
lightText := GetTheme().Text

// Switching the terminal background must rebuild the derived theme so the
// adaptive colours flip to the dark-mode branch.
SetTerminalCapabilities(true, colorprofile.TrueColor)
darkText := GetTheme().Text

if colorsEqual(lightText, darkText) {
t.Fatalf("derived theme Text did not change with background: %v", lightText)
}
}

func TestSetTerminalCapabilitiesPreservesExplicitTheme(t *testing.T) {
saveStyleState(t)

custom := DefaultTheme()
custom.Text = lipgloss.Color("#123456")
SetTheme(custom)

// An explicitly chosen theme must survive a capability change untouched.
SetTerminalCapabilities(true, colorprofile.TrueColor)
if got := GetTheme().Text; !colorsEqual(got, lipgloss.Color("#123456")) {
t.Fatalf("explicit theme Text changed after SetTerminalCapabilities: got %v", got)
}
}

func colorsEqual(a, b interface{ RGBA() (r, g, b, a uint32) }) bool {
ar, ag, ab, aa := a.RGBA()
br, bg, bb, ba := b.RGBA()
return ar == br && ag == bg && ab == bb && aa == ba
}
Loading
Loading