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`) 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 for one command
GH_STACK_THEME=light gh stack view
```

## Exit codes

| Code | Meaning |
Expand Down
7 changes: 7 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/tui/shared"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -35,6 +36,12 @@ 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, so
// the background-aware TUI palette can be forced when a terminal
// mis-detects its background.
PersistentPreRun: func(_ *cobra.Command, _ []string) {
shared.ApplyThemeOverride()
},
}

root.SetVersionTemplate("gh stack version {{.Version}}\n")
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`). They 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 internal/tui/modifyview/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -1465,7 +1465,7 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig {
{Icon: "○", Label: branchInfo},
}
if pendingSummary != "" {
yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
yellowStyle := lipgloss.NewStyle().Foreground(shared.ColorYellow)
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "■", Label: pendingSummary, IconStyle: &yellowStyle})
} else {
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "□", Label: "No pending changes"})
Expand Down
52 changes: 29 additions & 23 deletions internal/tui/modifyview/styles.go
Original file line number Diff line number Diff line change
@@ -1,42 +1,48 @@
package modifyview

import "github.com/charmbracelet/lipgloss"
import (
"github.com/charmbracelet/lipgloss"

"github.com/github/gh-stack/internal/tui/shared"
)

// Colors come from the background-aware palette in internal/tui/shared so the
// modify view reads well on both dark and light terminals.
var (
// Action annotation styles (modify-specific)
dropBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
foldBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
renameBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan
moveBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta/purple
insertBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
dropBadge = lipgloss.NewStyle().Foreground(shared.ColorRed) // drop
foldBadge = lipgloss.NewStyle().Foreground(shared.ColorYellow) // fold
renameBadge = lipgloss.NewStyle().Foreground(shared.ColorAccent) // rename
moveBadge = lipgloss.NewStyle().Foreground(shared.ColorPurple) // move
insertBadge = lipgloss.NewStyle().Foreground(shared.ColorGreen) // insert

// Branch name overrides for drop/fold/insert
dropBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Strikethrough(true) // red strikethrough
foldBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Strikethrough(true) // yellow strikethrough
insertBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
dropBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorRed).Strikethrough(true)
foldBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorYellow).Strikethrough(true)
insertBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorGreen)

// Connector color overrides for drop/fold/move/insert
dropConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
foldConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
movedConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta/purple
insertConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
dropConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorRed)
foldConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorYellow)
movedConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorPurple)
insertConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorGreen)

// Status line styles
statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
statusCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
statusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
statusDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
statusBarStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted)
statusCountStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true)
statusKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent)
statusDescStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted)

// Help overlay styles
helpOverlayStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8")).
BorderForeground(shared.ColorBorder).
Padding(1, 2)
helpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true)
helpDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
helpTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true)
helpKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true)
helpDescStyle = lipgloss.NewStyle().Foreground(shared.ColorText)
helpTitleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true).Underline(true)

// Transient message styles
transientErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
transientInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray
transientErrorStyle = lipgloss.NewStyle().Foreground(shared.ColorRed)
transientInfoStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted)
)
2 changes: 1 addition & 1 deletion internal/tui/shared/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) {
}

// disabledShortcutStyle renders both key and desc in dim gray.
var disabledShortcutStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
var disabledShortcutStyle = lipgloss.NewStyle().Foreground(ColorTextFaint)

// renderShortcutEntry renders a single shortcut, dimmed if disabled.
func renderShortcutEntry(sc ShortcutEntry) string {
Expand Down
8 changes: 4 additions & 4 deletions internal/tui/shared/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,16 @@ func ResolveConnectorStyle(node BranchNodeData, isFocused bool) (string, lipglos
// StatusIcon returns the appropriate status icon for a branch.
func StatusIcon(node BranchNodeData) string {
if node.Ref.IsMerged() {
return MergedIcon
return mergedIconStyle.Render(mergedGlyph)
}
if node.Ref.IsQueued() {
return QueuedIcon
return queuedIconStyle.Render(queuedGlyph)
}
if !node.IsLinear {
return WarningIcon
return warningIconStyle.Render(warningGlyph)
}
if node.PR != nil && node.PR.Number != 0 {
return OpenIcon
return openIconStyle.Render(openGlyph)
}
return ""
}
Expand Down
73 changes: 40 additions & 33 deletions internal/tui/shared/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,59 @@ import "github.com/charmbracelet/lipgloss"

var (
// Branch name styles
CurrentBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true)
NormalBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
MergedBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
TrunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)
CurrentBranchStyle = lipgloss.NewStyle().Foreground(ColorAccent).Bold(true)
NormalBranchStyle = lipgloss.NewStyle().Foreground(ColorText)
MergedBranchStyle = lipgloss.NewStyle().Foreground(ColorTextMuted)
TrunkStyle = lipgloss.NewStyle().Foreground(ColorTextMuted).Italic(true)

// Status indicators
MergedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("✓")
WarningIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠")
OpenIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("○")
QueuedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Render("◎")
// Status indicator glyphs. These are rendered at use-time (see StatusIcon)
// with the styles below so their adaptive colors resolve against the detected
// terminal background rather than being baked in at package-init time.
mergedGlyph = "✓"
warningGlyph = "⚠"
openGlyph = "○"
queuedGlyph = "◎"

mergedIconStyle = lipgloss.NewStyle().Foreground(ColorPurple)
warningIconStyle = lipgloss.NewStyle().Foreground(ColorYellow)
openIconStyle = lipgloss.NewStyle().Foreground(ColorGreen)
queuedIconStyle = lipgloss.NewStyle().Foreground(ColorYellow)

// PR status styles
PRLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true)
PROpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
PRMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
PRClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
PRDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
PRQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130"))
PRLinkStyle = lipgloss.NewStyle().Foreground(ColorText).Underline(true)
PROpenStyle = lipgloss.NewStyle().Foreground(ColorGreen)
PRMergedStyle = lipgloss.NewStyle().Foreground(ColorPurple)
PRClosedStyle = lipgloss.NewStyle().Foreground(ColorRed)
PRDraftStyle = lipgloss.NewStyle().Foreground(ColorGray)
PRQueuedStyle = lipgloss.NewStyle().Foreground(ColorYellow)

// Diff stats
AdditionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
DeletionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
AdditionsStyle = lipgloss.NewStyle().Foreground(ColorGreen)
DeletionsStyle = lipgloss.NewStyle().Foreground(ColorRed)

// Commit lines
CommitSHAStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
CommitSubjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
CommitTimeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
CommitSHAStyle = lipgloss.NewStyle().Foreground(ColorYellow)
CommitSubjectStyle = lipgloss.NewStyle().Foreground(ColorText)
CommitTimeStyle = lipgloss.NewStyle().Foreground(ColorTextMuted)

// Connector lines
ConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
ConnectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
ConnectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
ConnectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
ConnectorMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
ConnectorQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130"))
ConnectorStyle = lipgloss.NewStyle().Foreground(ColorBorder)
ConnectorDashedStyle = lipgloss.NewStyle().Foreground(ColorYellow)
ConnectorFocusedStyle = lipgloss.NewStyle().Foreground(ColorText)
ConnectorCurrentStyle = lipgloss.NewStyle().Foreground(ColorAccent)
ConnectorMergedStyle = lipgloss.NewStyle().Foreground(ColorPurple)
ConnectorQueuedStyle = lipgloss.NewStyle().Foreground(ColorYellow)

// Dim text
DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
DimStyle = lipgloss.NewStyle().Foreground(ColorTextFaint)

// Header styles
HeaderBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
HeaderTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
HeaderInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
HeaderInfoLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
HeaderShortcutKey = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
HeaderShortcutDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
HeaderBorderStyle = lipgloss.NewStyle().Foreground(ColorBorder)
HeaderTitleStyle = lipgloss.NewStyle().Foreground(ColorText).Bold(true)
HeaderInfoStyle = lipgloss.NewStyle().Foreground(ColorAccent)
HeaderInfoLabelStyle = lipgloss.NewStyle().Foreground(ColorTextMuted)
HeaderShortcutKey = lipgloss.NewStyle().Foreground(ColorText)
HeaderShortcutDesc = lipgloss.NewStyle().Foreground(ColorTextMuted)

// Expand/collapse icons
ExpandedIcon = "▾"
Expand Down
86 changes: 86 additions & 0 deletions internal/tui/shared/theme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package shared

import (
"os"
"strings"

"github.com/charmbracelet/lipgloss"
)

// This file defines the shared, background-aware color palette used by every
// gh-stack TUI (submit, view, modify).
//
// 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), so the
// right variant is picked automatically; terminals that don't answer the query
// fall back to the dark palette, preserving the original look.
//
// 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.
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.
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), and modify
// action badges.
ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW
ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, insert
ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT
ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA, fold
ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, move
ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, drop, errors

// 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"}
)

// ApplyThemeOverride 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 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 ApplyThemeOverride() {
switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) {
case "light":
lipgloss.SetHasDarkBackground(false)
case "dark":
lipgloss.SetHasDarkBackground(true)
}
}
Loading