Skip to content

Commit 797c62e

Browse files
authored
Adapt theme colors to light and dark terminals (#149)
* Make the TUIs adapt to light and dark terminal backgrounds The submit, view, and modify TUIs were tuned for dark terminals. On light or solarized-light backgrounds the result was hard to read and inverted: primary text used ANSI white (invisible on white), dim chrome used light grays (too faint), and accents used bright cyan (low contrast) — so "active" things looked lighter than "disabled" ones. Introduce a centralized, background-aware color palette and migrate all three TUIs to it: - internal/tui/shared/theme.go: a semantic palette of lipgloss.AdaptiveColor values (primary/muted/faint text, chrome/border, accent, PR-state colors, badge backgrounds, row shade, button, switch). lipgloss resolves the light/dark variant per render from the terminal background, which Bubble Tea detects at startup; terminals that don't report it fall back to dark, preserving the original look. - Replace every hardcoded ANSI color in shared/, submitview/, and modifyview/ with palette roles. The four pre-rendered status icons now render at use-time so their adaptive colors resolve correctly. The submit markdown preview picks glamour's light or dark style from the detected background. - GH_STACK_THEME=auto|light|dark forces the palette for terminals that mis-detect (some SSH/tmux setups); wired via the root command's PersistentPreRun before any render. Documented in the README and CLI docs. Neutral text/chrome use truecolor hex (GitHub Primer-inspired) for predictability across themes, including solarized which repurposes ANSI 8-15; lipgloss downsamples on terminals without truecolor. Tests verify the palette resolves differently for light vs dark and that GH_STACK_THEME is honored. * Apply background-aware colors to all command output Background detection and the GH_STACK_THEME override (added for the TUIs) only affected the interactive screens. Plain command output -- status messages and interactive prompts -- went through the mgutz/ansi library with fixed ANSI palette names (green/red/yellow/cyan/...), so it never adapted to the terminal background and could read poorly on light or solarized themes. Unify everything on the same adaptive palette so all colors react to the detected background and to GH_STACK_THEME. - Extract internal/theme, a foundational package with no internal dependencies, that owns: - the background-aware lipgloss.AdaptiveColor palette (moved out of internal/tui/shared), - ApplyOverride(), the GH_STACK_THEME=auto|light|dark logic, and - non-TUI colorizers (Success/Error/Warning/Blue/Magenta/Cyan/Gray/ Bold) plus FgSeqs(), which returns the raw start/reset escapes used to color the user's echoed prompt input. - internal/tui/shared/theme.go now re-exports the palette, so the TUI code keeps referring to shared.ColorX unchanged. - internal/config/config.go wires the Config.Color* funcs to the theme colorizers and drops mgutz/ansi (now an indirect dependency only). - cmd/utils.go colors the prompt icon and echoed input via theme. - cmd/root.go calls theme.ApplyOverride() in PersistentPreRun. Detection adds no cost: because the command package imports Bubble Tea, its init() already triggers (and caches) the terminal background query for every command, so the non-TUI colorizers just read the cached value. Terminals that don't answer the query fall back to the dark palette; GH_STACK_THEME=light|dark forces it. Colors are truecolor on capable terminals and downsample to the nearest ANSI color elsewhere. Tests: internal/theme covers palette adaptiveness, ApplyOverride, the colorizers, and FgSeqs; a new internal/config test verifies the wired-up Config.Color* funcs adapt to the background when color is enabled. Docs: README and the CLI reference note that GH_STACK_THEME now controls all colored output, not just the interactive screens. No behavior change beyond colors.
1 parent b6bcce1 commit 797c62e

21 files changed

Lines changed: 581 additions & 171 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,23 @@ gh stack submit
623623

624624
Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all.
625625

626+
## Terminal theme
627+
628+
The interactive screens (`submit`, `modify`, and `view`) and all colored command output (status messages, prompts) automatically adapt their colors to your terminal's background, so they're readable on both dark and light themes. The background is detected from the terminal; if a terminal doesn't report it (some SSH or `tmux` setups), the dark palette is used.
629+
630+
Set `GH_STACK_THEME` to force a palette if detection is wrong:
631+
632+
| Value | Behavior |
633+
|-------|----------|
634+
| `auto` (default) | Detect from the terminal background |
635+
| `light` | Force the light palette |
636+
| `dark` | Force the dark palette |
637+
638+
```bash
639+
# Force the light palette
640+
export GH_STACK_THEME=light && gh stack view
641+
```
642+
626643
## Exit codes
627644

628645
| Code | Meaning |

‎cmd/root.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77

88
"github.com/github/gh-stack/internal/config"
9+
"github.com/github/gh-stack/internal/theme"
910
"github.com/spf13/cobra"
1011
)
1112

@@ -35,6 +36,10 @@ locally, then push to GitHub to create your stack of PRs.`,
3536
Version: Version,
3637
SilenceUsage: true,
3738
SilenceErrors: true,
39+
// Honor GH_STACK_THEME (auto|light|dark) before any command renders
40+
PersistentPreRun: func(_ *cobra.Command, _ []string) {
41+
theme.ApplyOverride()
42+
},
3843
}
3944

4045
root.SetVersionTemplate("gh stack version {{.Version}}\n")

‎cmd/utils.go‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
"github.com/github/gh-stack/internal/git"
1515
"github.com/github/gh-stack/internal/github"
1616
"github.com/github/gh-stack/internal/stack"
17-
"github.com/mgutz/ansi"
17+
"github.com/github/gh-stack/internal/theme"
1818
)
1919

2020
// ErrSilent indicates the error has already been printed to the user.
@@ -97,24 +97,25 @@ func inputWithPrefill(cfg *config.Config, prompt, prefill string) (string, error
9797
}
9898
defer func() { _ = rr.RestoreTermMode() }()
9999

100-
// Render the prompt in survey style: green bold "?" + message
100+
// Render the prompt in survey style: green "?" + message
101101
icon := "?"
102102
useColor := cfg.Terminal.IsColorEnabled()
103103
if useColor {
104-
icon = ansi.Color("?", "green+hb")
104+
icon = theme.Success("?")
105105
}
106106
fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt)
107107

108-
// Set cyan color for the user's input text
108+
// Color the user's echoed input with the accent (cyan) color.
109+
cyanStart, cyanReset := theme.FgSeqs(theme.ColorAccent)
109110
if useColor {
110-
fmt.Fprint(cfg.Out, ansi.ColorCode("cyan"))
111+
fmt.Fprint(cfg.Out, cyanStart)
111112
}
112113

113114
line, err := rr.ReadLineWithDefault(0, []rune(prefill))
114115

115116
// Reset color after input
116117
if useColor {
117-
fmt.Fprint(cfg.Out, ansi.ColorCode("reset"))
118+
fmt.Fprint(cfg.Out, cyanReset)
118119
}
119120

120121
if err != nil {

‎docs/src/content/docs/reference/cli.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,19 @@ gh stack feedback "Support for reordering branches"
586586

587587
---
588588

589+
## Environment Variables
590+
591+
| Variable | Values | Description |
592+
|----------|--------|-------------|
593+
| `GH_STACK_THEME` | `auto` (default), `light`, `dark` | Controls the color palette of the interactive screens (`submit`, `modify`, `view`) and all colored command output. Colors adapt to your terminal background automatically; set this to force the light or dark palette when a terminal doesn't report its background (some SSH or `tmux` setups). |
594+
595+
```sh
596+
# Force the light palette for one command
597+
GH_STACK_THEME=light gh stack view
598+
```
599+
600+
---
601+
589602
## Exit Codes
590603

591604
| Code | Meaning |

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ require (
1212
github.com/cli/cli/v2 v2.93.0
1313
github.com/cli/go-gh/v2 v2.13.0
1414
github.com/cli/shurcooL-graphql v0.0.4
15-
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
1615
github.com/muesli/termenv v0.16.0
1716
github.com/spf13/cobra v1.10.2
1817
github.com/stretchr/testify v1.11.1
@@ -49,6 +48,7 @@ require (
4948
github.com/mattn/go-isatty v0.0.22 // indirect
5049
github.com/mattn/go-localereader v0.0.1 // indirect
5150
github.com/mattn/go-runewidth v0.0.23 // indirect
51+
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
5252
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
5353
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5454
github.com/muesli/cancelreader v0.2.2 // indirect

‎internal/config/color_test.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/charmbracelet/lipgloss"
7+
"github.com/muesli/termenv"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestColorFuncsAreBackgroundAware verifies that, when color is enabled, the
13+
// Config's color functions resolve to background-aware (adaptive) colors so plain
14+
// command output adapts to the terminal like the TUIs do.
15+
func TestColorFuncsAreBackgroundAware(t *testing.T) {
16+
// Force color on (even though tests have no tty) and a color-capable profile.
17+
t.Setenv("NO_COLOR", "")
18+
t.Setenv("CLICOLOR_FORCE", "1")
19+
beforeProfile := lipgloss.ColorProfile()
20+
beforeBg := lipgloss.HasDarkBackground()
21+
t.Cleanup(func() {
22+
lipgloss.SetColorProfile(beforeProfile)
23+
lipgloss.SetHasDarkBackground(beforeBg)
24+
})
25+
lipgloss.SetColorProfile(termenv.TrueColor)
26+
27+
cfg := New()
28+
require.True(t, cfg.Terminal.IsColorEnabled(), "CLICOLOR_FORCE should enable color")
29+
30+
for name, fn := range map[string]func(string) string{
31+
"ColorSuccess": cfg.ColorSuccess,
32+
"ColorError": cfg.ColorError,
33+
"ColorWarning": cfg.ColorWarning,
34+
"ColorCyan": cfg.ColorCyan,
35+
"ColorBlue": cfg.ColorBlue,
36+
"ColorMagenta": cfg.ColorMagenta,
37+
"ColorGray": cfg.ColorGray,
38+
} {
39+
t.Run(name, func(t *testing.T) {
40+
lipgloss.SetHasDarkBackground(true)
41+
dark := fn("x")
42+
lipgloss.SetHasDarkBackground(false)
43+
light := fn("x")
44+
assert.Contains(t, dark, "x")
45+
assert.NotEqual(t, dark, light, "%s should adapt to the terminal background", name)
46+
})
47+
}
48+
}

‎internal/config/config.go‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66

77
"github.com/cli/go-gh/v2/pkg/repository"
88
"github.com/cli/go-gh/v2/pkg/term"
9-
"github.com/mgutz/ansi"
109

1110
ghapi "github.com/github/gh-stack/internal/github"
11+
"github.com/github/gh-stack/internal/theme"
1212
)
1313

1414
// Config holds shared state for all commands.
@@ -69,14 +69,14 @@ func New() *Config {
6969
}
7070

7171
if terminal.IsColorEnabled() {
72-
cfg.ColorSuccess = ansi.ColorFunc("green")
73-
cfg.ColorError = ansi.ColorFunc("red")
74-
cfg.ColorWarning = ansi.ColorFunc("yellow")
75-
cfg.ColorBold = ansi.ColorFunc("default+b")
76-
cfg.ColorBlue = ansi.ColorFunc("blue")
77-
cfg.ColorMagenta = ansi.ColorFunc("magenta")
78-
cfg.ColorCyan = ansi.ColorFunc("cyan")
79-
cfg.ColorGray = ansi.ColorFunc("default+d")
72+
cfg.ColorSuccess = theme.Success
73+
cfg.ColorError = theme.Error
74+
cfg.ColorWarning = theme.Warning
75+
cfg.ColorBold = theme.Bold
76+
cfg.ColorBlue = theme.Blue
77+
cfg.ColorMagenta = theme.Magenta
78+
cfg.ColorCyan = theme.Cyan
79+
cfg.ColorGray = theme.Gray
8080
} else {
8181
noop := func(s string) string { return s }
8282
cfg.ColorSuccess = noop

‎internal/theme/theme.go‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Package theme defines gh-stack's background-aware color palette and the
2+
// helpers that render it, shared by both the interactive TUIs and ordinary
3+
// command output (prompts, status messages).
4+
//
5+
// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is
6+
// chosen at render time from the terminal's detected background. Bubble Tea
7+
// triggers that detection once at startup (see bubbletea/tea_init.go) — which,
8+
// because the command package imports Bubble Tea, happens for every command —
9+
// so the right variant is picked automatically and cached. Terminals that don't
10+
// answer the query fall back to the dark palette, preserving the original look.
11+
// GH_STACK_THEME (see ApplyOverride) forces a palette when detection is wrong.
12+
//
13+
// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette
14+
// indices so they render consistently across themes — notably solarized, which
15+
// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest
16+
// ANSI color on terminals without truecolor support.
17+
package theme
18+
19+
import (
20+
"os"
21+
"strings"
22+
23+
"github.com/charmbracelet/lipgloss"
24+
)
25+
26+
var (
27+
// ColorText is primary/emphasis ink: titles, branch names, links, active
28+
// keys, the description scrollbar thumb.
29+
ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"}
30+
// ColorTextMuted is secondary ink and dim chrome text: section labels,
31+
// shortcut descriptions, hints, trunk/merged branches, timestamps.
32+
ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"}
33+
// ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled
34+
// shortcuts.
35+
ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"}
36+
37+
// ColorBorder is structural chrome: panel borders, tree connectors, the
38+
// vertical spine, horizontal rules, scrollbar tracks, segmented-control frame.
39+
ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"}
40+
// ColorRowShade tints the focused (currently-viewed) row's background in the
41+
// left timeline. A neutral wash that reads as a subtle highlight on either
42+
// background — light gray on light terminals, and a lifted slate on dark
43+
// terminals so it stays visible against near-black backgrounds.
44+
ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"}
45+
46+
// ColorAccent is interactive emphasis: the current/focused branch, keyboard
47+
// shortcut keys, footer accents, and the cyan used in plain command output.
48+
ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"}
49+
50+
// Semantic status colors, mirroring how GitHub colors PR states. Reused for
51+
// diff stats (green/red), commit SHAs and warnings (yellow), modify action
52+
// badges, and the success/error/warning message icons.
53+
ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW, blue
54+
ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, success
55+
ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT, dim
56+
ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA
57+
ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, magenta
58+
ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, error
59+
60+
// ColorOnFill is text drawn on top of a solid colored fill (e.g. the green
61+
// "selected" pill): near-black on the lighter dark-mode fills, white on the
62+
// darker light-mode fills.
63+
ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"}
64+
65+
// ColorButtonFg/ColorButtonBg style the prominent inverted action button
66+
// (e.g. submit). The background inverts against the terminal so the button
67+
// stays prominent in both modes.
68+
ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"}
69+
ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"}
70+
)
71+
72+
// ApplyOverride honors the GH_STACK_THEME environment variable, forcing the
73+
// light or dark palette regardless of what the terminal reports. It must be
74+
// called before the first render (e.g. before any colored output or launching a
75+
// Bubble Tea program).
76+
//
77+
// GH_STACK_THEME=light force the light palette
78+
// GH_STACK_THEME=dark force the dark palette
79+
// GH_STACK_THEME=auto (or unset) detect from the terminal background
80+
//
81+
// Use this for terminals that don't answer the background query (some SSH/tmux
82+
// setups) and therefore mis-detect.
83+
func ApplyOverride() {
84+
switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) {
85+
case "light":
86+
lipgloss.SetHasDarkBackground(false)
87+
case "dark":
88+
lipgloss.SetHasDarkBackground(true)
89+
}
90+
}
91+
92+
// Colorize renders s in the given adaptive color for plain (non-TUI) output. It
93+
// emits ANSI only when the default renderer detects a color-capable terminal, so
94+
// callers should still gate on their own color-enabled check for consistency.
95+
func Colorize(c lipgloss.TerminalColor, s string) string {
96+
return lipgloss.NewStyle().Foreground(c).Render(s)
97+
}
98+
99+
// The following helpers color plain command output and prompts. They map the
100+
// semantic roles the command layer uses onto the adaptive palette.
101+
102+
// Success renders s in the success (green) color.
103+
func Success(s string) string { return Colorize(ColorGreen, s) }
104+
105+
// Error renders s in the error (red) color.
106+
func Error(s string) string { return Colorize(ColorRed, s) }
107+
108+
// Warning renders s in the warning (yellow) color.
109+
func Warning(s string) string { return Colorize(ColorYellow, s) }
110+
111+
// Blue renders s in the blue color.
112+
func Blue(s string) string { return Colorize(ColorBlue, s) }
113+
114+
// Magenta renders s in the magenta/purple color.
115+
func Magenta(s string) string { return Colorize(ColorPurple, s) }
116+
117+
// Cyan renders s in the accent (cyan/teal) color.
118+
func Cyan(s string) string { return Colorize(ColorAccent, s) }
119+
120+
// Gray renders s in the muted gray color.
121+
func Gray(s string) string { return Colorize(ColorGray, s) }
122+
123+
// Bold renders s in bold using the terminal's default foreground, which already
124+
// contrasts with either background.
125+
func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) }
126+
127+
// FgSeqs returns the raw SGR escape that starts foreground rendering in c and
128+
// the matching reset, for coloring text printed outside lipgloss (e.g. echoed
129+
// terminal input). Both are empty when the default renderer has no color
130+
// support.
131+
func FgSeqs(c lipgloss.TerminalColor) (start, reset string) {
132+
rendered := lipgloss.NewStyle().Foreground(c).Render("\x00")
133+
i := strings.IndexByte(rendered, '\x00')
134+
if i < 0 {
135+
return "", ""
136+
}
137+
return rendered[:i], rendered[i+1:]
138+
}

0 commit comments

Comments
 (0)