Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,23 @@ gh stack submit

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.

## Terminal theme

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.

Set `GH_STACK_THEME` to force a palette if detection is wrong:

| Value | Behavior |
|-------|----------|
| `auto` (default) | Detect from the terminal background |
| `light` | Force the light palette |
| `dark` | Force the dark palette |

```bash
# Force the light palette
export GH_STACK_THEME=light && gh stack view
```

## Exit codes

| Code | Meaning |
Expand Down
5 changes: 5 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/theme"
"github.com/spf13/cobra"
)

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

root.SetVersionTemplate("gh stack version {{.Version}}\n")
Expand Down
13 changes: 7 additions & 6 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
"github.com/mgutz/ansi"
"github.com/github/gh-stack/internal/theme"
)

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

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

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

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

// Reset color after input
if useColor {
fmt.Fprint(cfg.Out, ansi.ColorCode("reset"))
fmt.Fprint(cfg.Out, cyanReset)
}

if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,19 @@ gh stack feedback "Support for reordering branches"

---

## Environment Variables

| Variable | Values | Description |
|----------|--------|-------------|
| `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). |

```sh
# Force the light palette for one command
GH_STACK_THEME=light gh stack view
```

---

## Exit Codes

| Code | Meaning |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ require (
github.com/cli/cli/v2 v2.93.0
github.com/cli/go-gh/v2 v2.13.0
github.com/cli/shurcooL-graphql v0.0.4
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
Expand Down Expand Up @@ -49,6 +48,7 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
Expand Down
48 changes: 48 additions & 0 deletions internal/config/color_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package config

import (
"testing"

"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestColorFuncsAreBackgroundAware verifies that, when color is enabled, the
// Config's color functions resolve to background-aware (adaptive) colors so plain
// command output adapts to the terminal like the TUIs do.
func TestColorFuncsAreBackgroundAware(t *testing.T) {
// Force color on (even though tests have no tty) and a color-capable profile.
t.Setenv("NO_COLOR", "")
t.Setenv("CLICOLOR_FORCE", "1")
beforeProfile := lipgloss.ColorProfile()
beforeBg := lipgloss.HasDarkBackground()
t.Cleanup(func() {
lipgloss.SetColorProfile(beforeProfile)
lipgloss.SetHasDarkBackground(beforeBg)
})
lipgloss.SetColorProfile(termenv.TrueColor)

cfg := New()
require.True(t, cfg.Terminal.IsColorEnabled(), "CLICOLOR_FORCE should enable color")

for name, fn := range map[string]func(string) string{
"ColorSuccess": cfg.ColorSuccess,
"ColorError": cfg.ColorError,
"ColorWarning": cfg.ColorWarning,
"ColorCyan": cfg.ColorCyan,
"ColorBlue": cfg.ColorBlue,
"ColorMagenta": cfg.ColorMagenta,
"ColorGray": cfg.ColorGray,
} {
t.Run(name, func(t *testing.T) {
lipgloss.SetHasDarkBackground(true)
dark := fn("x")
lipgloss.SetHasDarkBackground(false)
light := fn("x")
assert.Contains(t, dark, "x")
assert.NotEqual(t, dark, light, "%s should adapt to the terminal background", name)
})
}
}
18 changes: 9 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import (

"github.com/cli/go-gh/v2/pkg/repository"
"github.com/cli/go-gh/v2/pkg/term"
"github.com/mgutz/ansi"

ghapi "github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/theme"
)

// Config holds shared state for all commands.
Expand Down Expand Up @@ -69,14 +69,14 @@ func New() *Config {
}

if terminal.IsColorEnabled() {
cfg.ColorSuccess = ansi.ColorFunc("green")
cfg.ColorError = ansi.ColorFunc("red")
cfg.ColorWarning = ansi.ColorFunc("yellow")
cfg.ColorBold = ansi.ColorFunc("default+b")
cfg.ColorBlue = ansi.ColorFunc("blue")
cfg.ColorMagenta = ansi.ColorFunc("magenta")
cfg.ColorCyan = ansi.ColorFunc("cyan")
cfg.ColorGray = ansi.ColorFunc("default+d")
cfg.ColorSuccess = theme.Success
cfg.ColorError = theme.Error
cfg.ColorWarning = theme.Warning
cfg.ColorBold = theme.Bold
cfg.ColorBlue = theme.Blue
cfg.ColorMagenta = theme.Magenta
cfg.ColorCyan = theme.Cyan
cfg.ColorGray = theme.Gray
} else {
noop := func(s string) string { return s }
cfg.ColorSuccess = noop
Expand Down
138 changes: 138 additions & 0 deletions internal/theme/theme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Package theme defines gh-stack's background-aware color palette and the
// helpers that render it, shared by both the interactive TUIs and ordinary
// command output (prompts, status messages).
//
// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is
// chosen at render time from the terminal's detected background. Bubble Tea
// triggers that detection once at startup (see bubbletea/tea_init.go) — which,
// because the command package imports Bubble Tea, happens for every command —
// so the right variant is picked automatically and cached. Terminals that don't
// answer the query fall back to the dark palette, preserving the original look.
// GH_STACK_THEME (see ApplyOverride) forces a palette when detection is wrong.
//
// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette
// indices so they render consistently across themes — notably solarized, which
// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest
// ANSI color on terminals without truecolor support.
package theme

import (
"os"
"strings"

"github.com/charmbracelet/lipgloss"
)

var (
// ColorText is primary/emphasis ink: titles, branch names, links, active
// keys, the description scrollbar thumb.
ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"}
// ColorTextMuted is secondary ink and dim chrome text: section labels,
// shortcut descriptions, hints, trunk/merged branches, timestamps.
ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"}
// ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled
// shortcuts.
ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"}

// ColorBorder is structural chrome: panel borders, tree connectors, the
// vertical spine, horizontal rules, scrollbar tracks, segmented-control frame.
ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"}
// ColorRowShade tints the focused (currently-viewed) row's background in the
// left timeline. A neutral wash that reads as a subtle highlight on either
// background — light gray on light terminals, and a lifted slate on dark
// terminals so it stays visible against near-black backgrounds.
ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"}

// ColorAccent is interactive emphasis: the current/focused branch, keyboard
// shortcut keys, footer accents, and the cyan used in plain command output.
ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"}

// Semantic status colors, mirroring how GitHub colors PR states. Reused for
// diff stats (green/red), commit SHAs and warnings (yellow), modify action
// badges, and the success/error/warning message icons.
ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW, blue
ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, success
ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT, dim
ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA
ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, magenta
ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, error

// ColorOnFill is text drawn on top of a solid colored fill (e.g. the green
// "selected" pill): near-black on the lighter dark-mode fills, white on the
// darker light-mode fills.
ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"}

// ColorButtonFg/ColorButtonBg style the prominent inverted action button
// (e.g. submit). The background inverts against the terminal so the button
// stays prominent in both modes.
ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"}
ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"}
)

// ApplyOverride honors the GH_STACK_THEME environment variable, forcing the
// light or dark palette regardless of what the terminal reports. It must be
// called before the first render (e.g. before any colored output or launching a
// Bubble Tea program).
//
// GH_STACK_THEME=light force the light palette
// GH_STACK_THEME=dark force the dark palette
// GH_STACK_THEME=auto (or unset) detect from the terminal background
//
// Use this for terminals that don't answer the background query (some SSH/tmux
// setups) and therefore mis-detect.
func ApplyOverride() {
switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) {
case "light":
lipgloss.SetHasDarkBackground(false)
case "dark":
lipgloss.SetHasDarkBackground(true)
}
}

// Colorize renders s in the given adaptive color for plain (non-TUI) output. It
// emits ANSI only when the default renderer detects a color-capable terminal, so
// callers should still gate on their own color-enabled check for consistency.
func Colorize(c lipgloss.TerminalColor, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s)
}

// The following helpers color plain command output and prompts. They map the
// semantic roles the command layer uses onto the adaptive palette.

// Success renders s in the success (green) color.
func Success(s string) string { return Colorize(ColorGreen, s) }

// Error renders s in the error (red) color.
func Error(s string) string { return Colorize(ColorRed, s) }

// Warning renders s in the warning (yellow) color.
func Warning(s string) string { return Colorize(ColorYellow, s) }

// Blue renders s in the blue color.
func Blue(s string) string { return Colorize(ColorBlue, s) }

// Magenta renders s in the magenta/purple color.
func Magenta(s string) string { return Colorize(ColorPurple, s) }

// Cyan renders s in the accent (cyan/teal) color.
func Cyan(s string) string { return Colorize(ColorAccent, s) }

// Gray renders s in the muted gray color.
func Gray(s string) string { return Colorize(ColorGray, s) }

// Bold renders s in bold using the terminal's default foreground, which already
// contrasts with either background.
func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) }

// FgSeqs returns the raw SGR escape that starts foreground rendering in c and
// the matching reset, for coloring text printed outside lipgloss (e.g. echoed
// terminal input). Both are empty when the default renderer has no color
// support.
func FgSeqs(c lipgloss.TerminalColor) (start, reset string) {
rendered := lipgloss.NewStyle().Foreground(c).Render("\x00")
i := strings.IndexByte(rendered, '\x00')
if i < 0 {
return "", ""
}
return rendered[:i], rendered[i+1:]
}
Loading
Loading