diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1ce8453f..e5adc47e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,6 +25,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai - `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`). - `internal/stack/`: Shared catalog (`/gh-stack`, JSON), conservative legacy migration, atomic saves, and short catalog locks. - `internal/worktree/`: Origin/owner identities, cleanliness preflight, scoped operations, and touched-ref recovery. +- `internal/theme/`: GitHub-branded palette (single source of all hex colors), plain-output colorizers, and `CanvasOutput`, which paints GitHub's canvas behind full-screen TUIs. - `internal/tui/`: bubbletea views (`stackview`, `modifyview`). ## Coding conventions @@ -42,5 +43,6 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai - Finish paused operations before switching preview stages or versions. Reject origin-only and unknown rebase execution modes before routing recovery; use the matching layer3 build in the recorded origin for origin-only journals. - Mutation locks coordinate gh-stack only, not Git commands/editors. Keep affected worktrees quiescent during rewrites. Pass the snapshot SHA (or prior `Context.Touched` SHA) to `Context.Start` before ref mutations; never claim an external commit as this operation's work during continuation. - Distributed modify preflights action/cascade targets and executes in recorded owners; only the origin may switch for unoccupied branches. Preserve dropped/folded source worktrees and refs. Before native continuation, preflight other worktrees, not remaining branches in the intentionally busy pending worktree. +- Colors come only from `internal/theme`. Run full-screen TUIs through `runFullScreenTUI` (canvas painting). Commands launched with `tea.ExecProcess` must set their own `Stdout`. For full architecture details, see [AGENTS.md](../AGENTS.md) in the repository root. diff --git a/AGENTS.md b/AGENTS.md index bff19886..1edf0dd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ internal/ modify/ # interactive stack modification state machine worktree/ # operation-owned worktree identities and ref recovery pr/ # PR template discovery + theme/ # GitHub-branded color palette, colorizers, full-screen canvas painting tui/ # bubbletea/bubbles/lipgloss terminal UI stackview/ # interactive stack visualization modifyview/ # interactive modify session UI @@ -147,3 +148,5 @@ if errors.As(err, &exitErr) { ... } - Rebase commands disable automatic maintenance through command-local configuration so detached `rerere gc` cannot race conflict handling. Repository settings and unrelated Git commands are unchanged. - Date-preserving rebase starts use the merge backend so Git persists the date setting across conflicts. Continuations use native saved settings, not start-only date flags. - The `.gitignore` ignores `/gh-stack` and `/gh-stack.exe` (the built binary). +- Colors: every hex value lives in `internal/theme` (Primer Brand hues on GitHub neutrals, as `lipgloss.AdaptiveColor` light/dark pairs; TUIs use the `internal/tui/shared` aliases). Don't hard-code colors in views. `TestPaletteContrast` enforces WCAG contrast on the GitHub canvases. +- Full-screen TUIs (`view`, `modify`, `submit`) run through `runFullScreenTUI` (`cmd/utils.go`), which paints GitHub's canvas via `theme.CanvasOutput` when the terminal is a truecolor TTY and `GH_STACK_BACKGROUND` isn't `terminal`. The wrapper paints right after the alternate screen is entered and restores colors right before it's left, which covers exits, panics, signals, and `tea.ExecProcess`. Before the program starts, `planRestore` probes the terminal (OSC 11/10 queries) and plans each color's restore. It uses a reset (OSC 111/110) when that brings the color back, so configuration changes made during the session still apply. Runtime-set colors, and terminals without reset support, get an explicit set. Never query the terminal while Bubble Tea owns its input. Commands run with `tea.ExecProcess` must set their own `Stdout`, or the child gets the non-terminal wrapper (see `editorCommand`). Inline TUIs (`checkout`, `merge`) and plain output never paint. diff --git a/README.md b/README.md index 2d73c401..3ac6e84e 100644 --- a/README.md +++ b/README.md @@ -749,21 +749,37 @@ Compared to the typical workflow, there's no need to name branches, run `git add ## 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. +gh-stack uses GitHub's colors: the Primer Brand blues, greens, and status colors, on GitHub's own dark or light canvas. + +The full-screen screens (`submit`, `modify`, and `view`) paint GitHub's canvas behind themselves, like the GitHub Copilot CLI: a `#0d1117` background on dark terminals and a `#ffffff` background on light ones. gh-stack restores your terminal's colors when the screen closes and while `$EDITOR` is open. Terminals that report their colors also get back colors set at runtime (for example by pywal or base16-shell); others return to their configured colors. Painting needs a truecolor terminal (most modern terminals, including iTerm2, Ghostty, kitty, WezTerm, Windows Terminal, and VS Code); other terminals keep their own background. In `tmux`, only the current pane is painted. + +All other output (status messages, prompts, and the inline `checkout` and `merge` screens) keeps your terminal's background. Colors everywhere adapt to whether that background is dark or light. 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 | +| `light` | Force the light palette (and the light canvas) | +| `dark` | Force the dark palette (and the dark canvas) | + +Set `GH_STACK_BACKGROUND` to control the canvas: + +| Value | Behavior | +|-------|----------| +| `github` (default) | Paint GitHub's canvas behind the full-screen screens | +| `terminal` | Keep your terminal's background and text colors everywhere | ```bash # Force the light palette export GH_STACK_THEME=light && gh stack view + +# Keep your terminal's own background in the full-screen screens +export GH_STACK_BACKGROUND=terminal ``` +If gh-stack is force-killed (for example with `kill -9`) while a full-screen screen is open, the terminal can't be restored automatically. Run `printf '\033]111\007\033]110\007'` or open a new tab to reset its colors. + ## Exit codes | Code | Meaning | diff --git a/cmd/add.go b/cmd/add.go index ddcaa568..4ac57674 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -93,7 +93,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if s.IsFullyMerged() { cfg.Warningf("All branches in this stack have been merged") - cfg.Printf("Consider creating a new stack with `%s`", cfg.ColorCyan("gh stack init")) + cfg.Printf("Consider creating a new stack with `%s`", cfg.ColorAccent("gh stack init")) return nil } @@ -101,8 +101,8 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // idx < 0 means we're on the trunk — that's allowed (we'll create // a new branch from it). Only block if we're in the middle of the stack. if idx >= 0 && idx < len(s.Branches)-1 { - cfg.Errorf("can only add branches to the top of the stack; run `%s` then `%s`", cfg.ColorCyan("gh stack top"), cfg.ColorCyan("gh stack add")) - cfg.Printf("Or to restructure your stack and insert a branch, use `%s`", cfg.ColorCyan("gh stack modify")) + cfg.Errorf("can only add branches to the top of the stack; run `%s` then `%s`", cfg.ColorAccent("gh stack top"), cfg.ColorAccent("gh stack add")) + cfg.Printf("Or to restructure your stack and insert a branch, use `%s`", cfg.ColorAccent("gh stack modify")) return ErrInvalidArgs } @@ -130,7 +130,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), currentBranch) cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch) - cfg.Printf("When you're ready for the next layer, run `%s` again", cfg.ColorCyan("gh stack add")) + cfg.Printf("When you're ready for the next layer, run `%s` again", cfg.ColorAccent("gh stack add")) return nil } diff --git a/cmd/alias.go b/cmd/alias.go index cb82765c..22b11f44 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -106,14 +106,14 @@ func runAlias(cfg *config.Config, name string, binDir string) error { // Check for an existing command with this name. if existing, err := exec.LookPath(name); err == nil { cfg.Errorf("a command named %q already exists at %s", name, existing) - cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst")) + cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorAccent("gh stack alias gst")) return ErrInvalidArgs } // Guard against overwriting an existing file that isn't on PATH if _, err := os.Stat(scriptPath); err == nil { cfg.Errorf("a file already exists at %s", scriptPath) - cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst")) + cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorAccent("gh stack alias gst")) return ErrInvalidArgs } @@ -130,7 +130,7 @@ func runAlias(cfg *config.Config, name string, binDir string) error { } cfg.Successf("Created alias %q at %s", name, scriptPath) - cfg.Printf("You can now use %s instead of %s", cfg.ColorCyan(name+" "), cfg.ColorCyan("gh stack ")) + cfg.Printf("You can now use %s instead of %s", cfg.ColorAccent(name+" "), cfg.ColorAccent("gh stack ")) // Warn if the bin directory is not in PATH. if !dirInPath(binDir) { diff --git a/cmd/checkout.go b/cmd/checkout.go index 61c28b5b..13d98f78 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -150,7 +150,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { if errors.Is(err, errRemoteBranchNotFound) { cfg.Errorf("no local or remote stack found for %q", opts.target) cfg.Printf("Try a stack or PR number with `%s`", - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack checkout ")) return ErrNotInStack } if err != nil { @@ -181,7 +181,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { cfg.Successf("Switched to %s", targetBranch) cfg.Printf("Stack: %s", s.DisplayChain()) cfg.Printf("Run `%s` to see the full stack", - cfg.ColorCyan("gh stack view")) + cfg.ColorAccent("gh stack view")) return nil } @@ -297,7 +297,7 @@ func checkoutRemoteStackByBranch(cfg *config.Config, sf *stack.StackFile, gitDir cfg.Errorf("branch %q belongs to multiple stacks on GitHub (%s)", branch, strings.Join(stackNumbers, ", ")) cfg.Printf("Use `%s` with a stack or PR number to choose one", - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack checkout ")) return nil, "", ErrDisambiguate } @@ -424,7 +424,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, } if allMerged { cfg.Infof("All PRs in this stack have been merged") - cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init")) + cfg.Printf("To start a new stack, use `%s`", cfg.ColorAccent("gh stack init")) return nil, "", ErrSilent } @@ -566,7 +566,7 @@ func handleCompositionConflict( } cfg.Printf(" Remote: (%s) <- %s", trunk, strings.Join(remoteBranches, " <- ")) cfg.Printf(" Unstack on remote or use `%s` to unstack locally", - cfg.ColorCyan("gh stack unstack --local")) + cfg.ColorAccent("gh stack unstack --local")) return nil, ErrConflict } @@ -758,8 +758,8 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string) if len(rows) == 0 { cfg.Infof("No stacks available to check out") cfg.Printf("Create a stack with `%s` or check out a stack by number with `%s`", - cfg.ColorCyan("gh stack init"), - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack init"), + cfg.ColorAccent("gh stack checkout ")) return nil, "", nil } diff --git a/cmd/init.go b/cmd/init.go index 35af9ea9..fe895ee2 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -149,7 +149,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if opts.adopt { cfg.Warningf("The --adopt flag is deprecated. Existing branches are now adopted automatically.") cfg.Printf("You can simply run: %s", - cfg.ColorCyan("gh stack init ...")) + cfg.ColorAccent("gh stack init ...")) } // --- Branch collection --- @@ -327,7 +327,7 @@ func runInteractiveInitWithPreflight(cfg *config.Config, sf *stack.StackFile, tr cfg.Printf("Initializing a stack from %s.", trunk) cfg.Printf("Have multiple branches already? Run: %s", - cfg.ColorCyan("gh stack init ...")) + cfg.ColorAccent("gh stack init ...")) cfg.Printf("") var branchName string @@ -467,12 +467,12 @@ func printWhatsNextInWorktree(cfg *config.Config, s *stack.Stack, branches []str cfg.Printf("") cfg.Printf("What's next:") if hasAdopted { - cfg.Printf(" • see the full stack: %s", cfg.ColorCyan("gh stack view")) - cfg.Printf(" • move between branches: %s", cfg.ColorCyan("gh stack switch")) - cfg.Printf(" • link these PRs into a Stack on GitHub: %s", cfg.ColorCyan("gh stack submit")) + cfg.Printf(" • see the full stack: %s", cfg.ColorAccent("gh stack view")) + cfg.Printf(" • move between branches: %s", cfg.ColorAccent("gh stack switch")) + cfg.Printf(" • link these PRs into a Stack on GitHub: %s", cfg.ColorAccent("gh stack submit")) } else { - cfg.Printf(" • commit your work as usual, then add a layer: %s", cfg.ColorCyan("gh stack add")) - cfg.Printf(" • see your stack any time: %s", cfg.ColorCyan("gh stack view")) - cfg.Printf(" • when ready to open PRs: %s", cfg.ColorCyan("gh stack submit")) + cfg.Printf(" • commit your work as usual, then add a layer: %s", cfg.ColorAccent("gh stack add")) + cfg.Printf(" • see your stack any time: %s", cfg.ColorAccent("gh stack view")) + cfg.Printf(" • when ready to open PRs: %s", cfg.ColorAccent("gh stack submit")) } } diff --git a/cmd/merge.go b/cmd/merge.go index 306c1f33..e7957d14 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -187,7 +187,7 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error { if blocker != nil { top := candidates[len(candidates)-1].Number cfg.Errorf("cannot merge the whole stack: pull request #%d is %s", blocker.Number, blockerState(blocker)) - cfg.Printf("Merge up to #%d with `%s`", top, cfg.ColorCyan(fmt.Sprintf("gh stack merge %d", top))) + cfg.Printf("Merge up to #%d with `%s`", top, cfg.ColorAccent(fmt.Sprintf("gh stack merge %d", top))) return ErrInvalidArgs } targetPR = candidates[len(candidates)-1].Number @@ -264,12 +264,12 @@ func resolveActiveRemoteStack(cfg *config.Config, client github.ClientOps) (*git stacks := sf.FindAllStacksForBranch(currentBranch) if len(stacks) == 0 { cfg.Errorf("current branch %q is not part of a stack", currentBranch) - cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorCyan("gh stack merge [number]")) + cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorAccent("gh stack merge [number]")) return nil, ErrNotInStack } if len(stacks) > 1 { cfg.Errorf("branch %q belongs to multiple stacks", currentBranch) - cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorCyan("gh stack merge [number]")) + cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorAccent("gh stack merge [number]")) return nil, ErrDisambiguate } s := stacks[0] diff --git a/cmd/modify.go b/cmd/modify.go index c4b6d53e..862231ba 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -92,7 +92,7 @@ func runModify(cfg *config.Config) error { // before opening the TUI, mirroring submit's "nothing to submit" behavior. if s.IsFullyMerged() { cfg.Warningf("All branches in this stack have been merged") - cfg.Printf("There's nothing to modify — start a new stack with `%s`", cfg.ColorCyan("gh stack init")) + cfg.Printf("There's nothing to modify — start a new stack with `%s`", cfg.ColorAccent("gh stack init")) return nil } @@ -117,13 +117,7 @@ func runModify(cfg *config.Config) error { // Run the TUI model := modifyview.New(modifyNodes, s.Trunk, Version) - p := tea.NewProgram( - model, - tea.WithAltScreen(), - tea.WithMouseAllMotion(), - ) - - finalModel, err := p.Run() + finalModel, err := runFullScreenTUI(cfg, model, tea.WithMouseAllMotion()) if err != nil { return fmt.Errorf("running TUI: %w", err) } @@ -177,7 +171,7 @@ func runModify(cfg *config.Config) error { cfg.Printf("") cfg.Printf("Or restore the stack to its pre-modify state with `%s`", - cfg.ColorCyan("gh stack modify --abort")) + cfg.ColorAccent("gh stack modify --abort")) return ErrConflict } @@ -211,7 +205,7 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) { for _, d := range result.DroppedPRs { cfg.Printf(" Dropped: %s (PR #%d remains open — close with `%s`)", - d.Branch, d.PRNumber, cfg.ColorCyan(fmt.Sprintf("gh pr close %d", d.PRNumber))) + d.Branch, d.PRNumber, cfg.ColorAccent(fmt.Sprintf("gh pr close %d", d.PRNumber))) } if result.MovedBranches > 0 { @@ -222,7 +216,7 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) { cfg.Printf("") if result.NeedsSubmit { cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) } } @@ -260,7 +254,7 @@ func runModifyAbort(cfg *config.Config) error { if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil { cfg.Errorf("recovery failed: %s", err) cfg.Printf("Recovery state was retained. Resolve the reported problem and retry `%s`.", - cfg.ColorCyan("gh stack modify --abort")) + cfg.ColorAccent("gh stack modify --abort")) return ErrSilent } cfg.Successf("Stack restored successfully") @@ -269,7 +263,7 @@ func runModifyAbort(cfg *config.Config) error { case modify.PhasePendingSubmit: cfg.Printf("A modify completed but the stack has not been submitted") cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) return nil default: @@ -333,8 +327,8 @@ func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) { if inProgress { cfg.Errorf("a rebase is currently in progress") cfg.Printf("Complete the rebase with `%s` or abort with `%s`", - cfg.ColorCyan("gh stack rebase --continue"), - cfg.ColorCyan("gh stack rebase --abort")) + cfg.ColorAccent("gh stack rebase --continue"), + cfg.ColorAccent("gh stack rebase --abort")) return nil, ErrRebaseActive } @@ -409,18 +403,18 @@ func checkNoModifyInProgress(cfg *config.Config, gitDir string) error { case modify.PhaseApplying: cfg.Errorf("a previous modify session was interrupted") cfg.Printf("Run `%s` to restore your stack", - cfg.ColorCyan("gh stack modify --abort")) + cfg.ColorAccent("gh stack modify --abort")) return ErrModifyRecovery case modify.PhaseConflict: cfg.Errorf("a modify has unresolved conflicts") cfg.Printf("Run `%s` to continue, or `%s` to restore your stack", - cfg.ColorCyan("gh stack modify --continue"), - cfg.ColorCyan("gh stack modify --abort")) + cfg.ColorAccent("gh stack modify --continue"), + cfg.ColorAccent("gh stack modify --abort")) return ErrSilent case modify.PhasePendingSubmit: cfg.Errorf("a modify was completed but the stack has not been submitted yet") cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) return ErrSilent default: cfg.Errorf("unexpected modify state phase: %s", state.Phase) diff --git a/cmd/push.go b/cmd/push.go index 9455a309..1cab4d67 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -129,9 +129,9 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } if hasBranchWithoutPR { cfg.Printf("To create PRs for this stack, run `%s`", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) } else { - cfg.Printf("Run `%s` to see your stack of PRs", cfg.ColorCyan("gh stack view")) + cfg.Printf("Run `%s` to see your stack of PRs", cfg.ColorAccent("gh stack view")) } return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index a567c119..15c97576 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -299,9 +299,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run `%s`", - rebaseResult.ConflictBranch, cfg.ColorCyan("gh stack rebase --continue")) + rebaseResult.ConflictBranch, cfg.ColorAccent("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", - cfg.ColorCyan("gh stack rebase --abort")) + cfg.ColorAccent("gh stack rebase --abort")) return ErrConflict } @@ -331,7 +331,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { cfg.Printf("%s rebased locally with %s", rangeDesc, trunk.Describe()) } cfg.Printf("To push up your changes, run `%s`", - cfg.ColorCyan("gh stack push")) + cfg.ColorAccent("gh stack push")) return nil } @@ -483,9 +483,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { printConflictDetails(cfg, result.ConflictBase) cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run `%s`", - result.ConflictBranch, cfg.ColorCyan("gh stack rebase --continue")) + result.ConflictBranch, cfg.ColorAccent("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", - cfg.ColorCyan("gh stack rebase --abort")) + cfg.ColorAccent("gh stack rebase --abort")) return ErrConflict } } @@ -648,7 +648,7 @@ func finishLegacyRebase(cfg *config.Config, gitDir string, state *rebaseState, s cfg.Printf("All branches in stack rebased locally with %s", trunkRef) } cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) return nil } @@ -733,10 +733,10 @@ func printConflictDetailsAt(cfg *config.Config, ops git.Ops, path, branch, conti cfg.Printf("") cfg.Printf("%s", cfg.ColorBold("To resolve:")) cfg.Printf(" 1. Open each conflicted file and look for conflict markers:") - cfg.Printf(" %s (incoming changes from %s)", cfg.ColorCyan("<<<<<<< HEAD"), branch) - cfg.Printf(" %s", cfg.ColorCyan("=======")) - cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>")) + cfg.Printf(" %s (incoming changes from %s)", cfg.ColorAccent("<<<<<<< HEAD"), branch) + cfg.Printf(" %s", cfg.ColorAccent("=======")) + cfg.Printf(" %s (changes being rebased)", cfg.ColorAccent(">>>>>>>")) cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers") - cfg.Printf(" 3. Stage resolved files: `%s`", cfg.ColorCyan("git add ")) - cfg.Printf(" 4. Continue: `%s`", cfg.ColorCyan(continueCmd)) + cfg.Printf(" 3. Stage resolved files: `%s`", cfg.ColorAccent("git add ")) + cfg.Printf(" 4. Continue: `%s`", cfg.ColorAccent(continueCmd)) } diff --git a/cmd/rebase_worktree.go b/cmd/rebase_worktree.go index cbb36c2e..2d509075 100644 --- a/cmd/rebase_worktree.go +++ b/cmd/rebase_worktree.go @@ -285,7 +285,7 @@ func continueWorktreeRebase(cfg *config.Config, dir string, state *rebaseState) return err } cfg.Successf("All branches in stack rebased locally") - cfg.Printf("To push your changes, run `%s`", cfg.ColorCyan("gh stack push")) + cfg.Printf("To push your changes, run `%s`", cfg.ColorAccent("gh stack push")) return nil } diff --git a/cmd/submit.go b/cmd/submit.go index 36fe8c82..ada90f3e 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -289,7 +289,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { if pendingModify { if !allPRsSubmitted || (!stackSynced && len(activeBranches) > 1) { cfg.Errorf("modify is not fully submitted; recovery state was retained") - cfg.Printf("Finish submitting all remaining PRs, then retry `%s`", cfg.ColorCyan("gh stack submit")) + cfg.Printf("Finish submitting all remaining PRs, then retry `%s`", cfg.ColorAccent("gh stack submit")) return ErrSilent } if err := clearPendingModifyState(cfg, s, gitDir); err != nil { @@ -347,8 +347,7 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack // input; under that volume bubbletea can split an SGR mouse sequence across // reads, leaking its bytes as text into a focused title/description field // while scrolling. We don't use idle-hover, so cell-motion loses nothing. - p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) - final, err := p.Run() + final, err := runFullScreenTUI(cfg, model, tea.WithMouseCellMotion()) if err != nil { return nil, false, fmt.Errorf("running submit TUI: %w", err) } @@ -623,7 +622,7 @@ func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *st cfg.Warningf("Every PR in this stack has already been merged on GitHub") cfg.Printf("Adding to a fully merged stack isn't supported — starting a new stack for your %d unmerged %s based on %s", - len(forkBranches), plural(len(forkBranches), "branch", "branches"), cfg.ColorCyan(trunk.Branch)) + len(forkBranches), plural(len(forkBranches), "branch", "branches"), cfg.ColorAccent(trunk.Branch)) // Decide the fate of the original (fully merged) stack: keep it as a record // only if at least one of its branches still exists locally; otherwise drop @@ -748,7 +747,7 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S if !proceed { cfg.Canceled = true cfg.Printf("Skipping stack recreation — run `%s` when ready", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) return true, errInterrupt } } @@ -758,7 +757,7 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S remote, lookupErr := remoteStackByID(client, state.PriorRemoteStackID) if lookupErr != nil { cfg.Warningf("Failed to look up existing stack: %v", lookupErr) - cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) + cfg.Printf("Run `%s` again to retry", cfg.ColorAccent("gh stack submit")) return true, lookupErr } if remote == nil { @@ -771,7 +770,7 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S cfg.Printf("Previous stack already deleted on GitHub") } else { cfg.Warningf("Failed to delete existing stack: %v", err) - cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) + cfg.Printf("Run `%s` again to retry", cfg.ColorAccent("gh stack submit")) return true, err } } else if !dissolved && !prepareStackRecreation(s, remaining) { @@ -888,7 +887,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta // automatically. cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them first") cfg.Printf(" Run `%s` to import a stack, or unstack the PRs from the web", - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack checkout ")) return false } @@ -904,7 +903,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack", formatPRList(dropped), plural(len(dropped), "is", "are")) cfg.Printf(" Run `%s` to import the full stack", - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack checkout ")) return false } @@ -1087,7 +1086,7 @@ func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int } cfg.Warningf("One or more PRs are already part of a different stack on GitHub") cfg.Printf(" Run `%s` to import the existing stack, or unstack the PRs from the web", - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack checkout ")) return false } diff --git a/cmd/sync.go b/cmd/sync.go index d3800a13..f3992eb4 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -240,7 +240,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } cfg.Printf("Branches restored to their pre-rebase state") cfg.Printf(" Run `%s` to resolve conflicts interactively.", - cfg.ColorCyan("gh stack rebase")) + cfg.ColorAccent("gh stack rebase")) // Persist refreshed PR state even on conflict, then bail out // before pushing or reporting success. @@ -297,10 +297,10 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if !force { cfg.Warningf("Push failed — branches may need force push after rebase") cfg.Printf(" Run `%s` to push with --force-with-lease.", - cfg.ColorCyan("gh stack push")) + cfg.ColorAccent("gh stack push")) } else { cfg.Warningf("Push failed: %v", err) - cfg.Printf(" Run `%s` to retry.", cfg.ColorCyan("gh stack push")) + cfg.Printf(" Run `%s` to retry.", cfg.ColorAccent("gh stack push")) } } else { cfg.Successf("Pushed %d branches", len(branches)) diff --git a/cmd/unstack.go b/cmd/unstack.go index 40dd9bee..972a30b9 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -94,7 +94,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { // --local never contacts GitHub, and there is nothing to remove // locally, so there is nothing to do. cfg.Errorf("stack %s is not tracked locally", cfg.StackLink(opts.stackNumber)) - cfg.Printf("Omit %s to unstack it on GitHub", cfg.ColorCyan("--local")) + cfg.Printf("Omit %s to unstack it on GitHub", cfg.ColorAccent("--local")) return ErrNotInStack } return runRemoteUnstack(cfg, opts.stackNumber) diff --git a/cmd/utils.go b/cmd/utils.go index 70cf45de..5f89c714 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/AlecAivazis/survey/v2/terminal" + tea "github.com/charmbracelet/bubbletea" "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" @@ -134,9 +135,31 @@ func ensureStackNumber(client github.ClientOps, s *stack.Stack) (int, error) { return number, nil } +// runFullScreenTUI runs a Bubble Tea program on the alternate screen, painting +// the GitHub canvas behind it when the terminal supports it. +func runFullScreenTUI(cfg *config.Config, model tea.Model, opts ...tea.ProgramOption) (tea.Model, error) { + opts = append([]tea.ProgramOption{tea.WithAltScreen()}, opts...) + if out := fullScreenCanvas(cfg); out != nil { + defer out.Restore() + opts = append(opts, tea.WithOutput(out)) + } + return tea.NewProgram(model, opts...).Run() +} + +// fullScreenCanvas returns the program output that paints the GitHub canvas +// while a full-screen TUI is on screen and restores the terminal's colors on +// every exit path (see theme.CanvasOutput), or nil when the canvas is +// unsupported or disabled. +func fullScreenCanvas(cfg *config.Config) *theme.CanvasOutput { + if !theme.CanvasSupported(cfg.Terminal.IsTerminalOutput(), cfg.Terminal.IsColorEnabled()) { + return nil + } + return theme.NewCanvasOutput(cfg.Out) +} + // promptInput prompts the user for a single line of text input. The user's -// input is rendered in the accent (cyan) color for visual distinction from the -// prompt message. +// input is rendered in the accent color for visual distinction from the prompt +// message. func promptInput(cfg *config.Config, prompt string) (string, error) { if cfg.InputFn != nil { return cfg.InputFn(prompt) @@ -157,17 +180,17 @@ func promptInput(cfg *config.Config, prompt string) (string, error) { } fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt) - // Color the user's echoed input with the accent (cyan) color. - cyanStart, cyanReset := theme.FgSeqs(theme.ColorAccent) + // Color the user's echoed input with the accent color. + accentStart, accentReset := theme.FgSeqs(theme.ColorAccent) if useColor { - fmt.Fprint(cfg.Out, cyanStart) + fmt.Fprint(cfg.Out, accentStart) } line, err := rr.ReadLine(0) // Reset color after input if useColor { - fmt.Fprint(cfg.Out, cyanReset) + fmt.Fprint(cfg.Out, accentReset) } if err != nil { @@ -277,7 +300,7 @@ func reportBranchNotInStack(cfg *config.Config, branch string, branchFromArg boo cfg.Errorf("current branch %q is not part of a stack", branch) } cfg.Printf("Checkout an existing stack using `%s` or create a new stack using `%s`", - cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) + cfg.ColorAccent("gh stack checkout"), cfg.ColorAccent("gh stack init")) } // lookupStackByNumber looks up the locally tracked stack whose stack number @@ -1234,7 +1257,7 @@ func trunkWithoutRemote(cfg *config.Config, trunk, remote string) (trunkTarget, if trackedRemote, err := git.UpstreamRemote(trunk); err == nil && trackedRemote != "" { cfg.Errorf("%s no longer exists on %s", trunk, remote) cfg.Printf(" Re-point the stack at an existing trunk branch, or use `%s`.", - cfg.ColorCyan("gh stack rebase --no-trunk")) + cfg.ColorAccent("gh stack rebase --no-trunk")) return trunkTarget{}, ErrSilent } @@ -1683,7 +1706,7 @@ func resolvePR(cfg *config.Config, sf *stack.StackFile, target string) (*stack.S "no locally tracked stack found for %q\n"+ "To pull down a stack from remote, use the PR number: `%s`", target, - cfg.ColorCyan("gh stack checkout "), + cfg.ColorAccent("gh stack checkout "), ) } @@ -1805,8 +1828,8 @@ func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, erro if save { if saveErr := git.SaveRemote(selectedRemote); saveErr == nil { cfg.Successf("Saved %q as the default remote for gh stack", selectedRemote) - cfg.Printf("To change later, run: %s", cfg.ColorCyan("git config gh-stack.remote ")) - cfg.Printf("To clear, run: %s", cfg.ColorCyan("git config --unset gh-stack.remote")) + cfg.Printf("To change later, run: %s", cfg.ColorAccent("git config gh-stack.remote ")) + cfg.Printf("To clear, run: %s", cfg.ColorAccent("git config --unset gh-stack.remote")) } else { cfg.Warningf("Could not save remote preference: %v", saveErr) } @@ -2113,7 +2136,7 @@ func resolveStackDivergence(cfg *config.Config, client github.ClientOps, sf *sta if !cfg.IsInteractive() { cfg.Printf(" Re-run in an interactive terminal to resolve, or import the remote stack with `%s`.", - cfg.ColorCyan("gh stack checkout ")) + cfg.ColorAccent("gh stack checkout ")) cfg.Infof("Sync aborted — no changes were made") return remoteReconcileResult{stop: true}, nil } @@ -2272,8 +2295,8 @@ func resolveDivergenceDeleteRemote(cfg *config.Config, client github.ClientOps, cfg.Printf("") cfg.Printf("Your local branches and stack order are unchanged.") - cfg.Printf(" Run `%s` to recreate the stack on GitHub.", cfg.ColorCyan("gh stack submit")) - cfg.Printf(" Run `%s` first if you want to change the stack's structure.", cfg.ColorCyan("gh stack modify")) + cfg.Printf(" Run `%s` to recreate the stack on GitHub.", cfg.ColorAccent("gh stack submit")) + cfg.Printf(" Run `%s` first if you want to change the stack's structure.", cfg.ColorAccent("gh stack modify")) return res, nil } diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 48780835..f4211885 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -10,12 +10,14 @@ import ( "testing" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/charmbracelet/lipgloss" "github.com/cli/go-gh/v2/pkg/repository" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" + "github.com/muesli/termenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -606,7 +608,7 @@ func TestReportWorktreeOwner_Command(t *testing.T) { if colored { color = func(s string) string { return "\x1b[36m" + s + "\x1b[0m" } } - cfg.ColorCyan = color + cfg.ColorAccent = color target := "skarim/worktrees-distributed-rebase-sync" reportWorktreeOwner(cfg, target, tt.path) @@ -2051,3 +2053,47 @@ func TestUpdateBaseSHAsPreservesLastValidBase(t *testing.T) { assert.Equal(t, "old-parent", s.Branches[1].Base) assert.Equal(t, "child-tip", s.Branches[1].Head) } + +// TestFullScreenCanvas verifies that the full-screen TUIs paint the GitHub +// canvas only on a color-enabled truecolor terminal, and honor the +// GH_STACK_BACKGROUND opt-out. +func TestFullScreenCanvas(t *testing.T) { + tests := []struct { + name string + env map[string]string + profile termenv.Profile + want bool + }{ + {name: "truecolor terminal", profile: termenv.TrueColor, want: true}, + {name: "explicit github background", env: map[string]string{"GH_STACK_BACKGROUND": "github"}, profile: termenv.TrueColor, want: true}, + {name: "terminal background opt-out", env: map[string]string{"GH_STACK_BACKGROUND": "terminal"}, profile: termenv.TrueColor}, + {name: "NO_COLOR", env: map[string]string{"NO_COLOR": "1"}, profile: termenv.TrueColor}, + {name: "dumb terminal", env: map[string]string{"TERM": "dumb"}, profile: termenv.TrueColor}, + {name: "256-color terminal", profile: termenv.ANSI256}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := lipgloss.ColorProfile() + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + lipgloss.SetColorProfile(tt.profile) + for key, value := range map[string]string{ + "GH_FORCE_TTY": "1", "TERM": "xterm-256color", + "NO_COLOR": "", "CLICOLOR": "", "GH_STACK_BACKGROUND": "", + } { + t.Setenv(key, value) + } + for key, value := range tt.env { + t.Setenv(key, value) + } + cfg, outR, errR := config.NewTestConfig() + + out := fullScreenCanvas(cfg) + + assert.Equal(t, tt.want, out != nil) + if out != nil { + assert.Equal(t, cfg.Out.Fd(), out.Fd(), "paints the command's own output") + } + commandOutput(t, cfg, outR, errR) + }) + } +} diff --git a/cmd/view.go b/cmd/view.go index 548f0488..99342cb3 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -166,7 +166,7 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { indicator := branchStatusIndicator(cfg, s, b) prSuffix := shortPRSuffix(cfg, b, repoHost, repoOwner, repoName) if b.Branch == currentBranch { - cfg.Outf("» %s%s%s %s\n", cfg.ColorBold(b.Branch), indicator, prSuffix, cfg.ColorCyan("(current)")) + cfg.Outf("» %s%s%s %s\n", cfg.ColorBold(b.Branch), indicator, prSuffix, cfg.ColorAccent("(current)")) } else if merged { cfg.Outf("│ %s%s%s\n", cfg.ColorGray(b.Branch), indicator, prSuffix) } else if queued { @@ -315,13 +315,7 @@ func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string, prDet model := stackview.New(reversed, s.Trunk, Version, s.Number).WithStackURL(cfg.StackURL(s.Number)) - p := tea.NewProgram( - model, - tea.WithAltScreen(), - tea.WithMouseAllMotion(), - ) - - finalModel, err := p.Run() + finalModel, err := runFullScreenTUI(cfg, model, tea.WithMouseAllMotion()) if err != nil { return fmt.Errorf("running TUI: %w", err) } @@ -393,7 +387,7 @@ func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) er branchName := cfg.ColorMagenta(b.Branch) if isCurrent { - branchName = cfg.ColorCyan(b.Branch + " (current)") + branchName = cfg.ColorAccent(b.Branch + " (current)") } fmt.Fprintf(&buf, "%s %s %s%s\n", bullet, branchName, indicator, prInfo) diff --git a/cmd/worktree_utils.go b/cmd/worktree_utils.go index a96e56a7..7225900f 100644 --- a/cmd/worktree_utils.go +++ b/cmd/worktree_utils.go @@ -404,7 +404,7 @@ func reportWorktreeOwner(cfg *config.Config, target, path string) { cfg.Printf(" Your current checkout is unchanged.") cfg.Printf("") cfg.Printf("To work on this branch, run:") - cfg.Printf(" %s", cfg.ColorCyan("cd "+commandPath)) + cfg.Printf(" %s", cfg.ColorAccent("cd "+commandPath)) } func checkoutWorktreeBranch(cfg *config.Config, target string, printPath bool) error { diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 97047dd9..0b4bb971 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -751,13 +751,17 @@ gh stack feedback "Support for reordering branches" | 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). | +| `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 (and the matching canvas) when a terminal doesn't report its background (some SSH or `tmux` setups). | +| `GH_STACK_BACKGROUND` | `github` (default), `terminal` | Controls whether the full-screen screens (`submit`, `modify`, `view`) paint GitHub's canvas (`#0d1117` on dark terminals, `#ffffff` on light ones) behind themselves. Painting needs a truecolor terminal. The terminal's colors are restored when the screen closes and while `$EDITOR` is open. Set to `terminal` to keep your terminal's own background and text colors. | | `GH_STACK_HYPERLINKS` | `0`, `1` | Disables or enables OSC 8 hyperlinks for PR and stack references when terminal detection is incorrect. Text output falls back to full URLs; compact TUIs keep plain labels. Does not control application-handled clicks in `view` and `submit`. | | `GH_STACK_NO_UPDATE_NOTIFIER` | Any non-empty value | Disables gh-stack's background release checks and upgrade notices. For example, set to `1` in automation that requires quiet stderr. | ```sh # Force the light palette for one command GH_STACK_THEME=light gh stack view + +# Keep the terminal's own background in the full-screen screens +GH_STACK_BACKGROUND=terminal gh stack submit ``` --- diff --git a/internal/config/color_test.go b/internal/config/color_test.go index 9c40a3db..d5179b19 100644 --- a/internal/config/color_test.go +++ b/internal/config/color_test.go @@ -31,7 +31,7 @@ func TestColorFuncsAreBackgroundAware(t *testing.T) { "ColorSuccess": cfg.ColorSuccess, "ColorError": cfg.ColorError, "ColorWarning": cfg.ColorWarning, - "ColorCyan": cfg.ColorCyan, + "ColorAccent": cfg.ColorAccent, "ColorBlue": cfg.ColorBlue, "ColorMagenta": cfg.ColorMagenta, "ColorGray": cfg.ColorGray, diff --git a/internal/config/config.go b/internal/config/config.go index c37eaba3..7fa4a64e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,7 +26,7 @@ type Config struct { ColorBold func(string) string ColorBlue func(string) string ColorMagenta func(string) string - ColorCyan func(string) string + ColorAccent func(string) string ColorGray func(string) string // GitHubClientOverride, when non-nil, is returned by GitHubClient() @@ -92,7 +92,7 @@ func New() *Config { cfg.ColorBold = theme.Bold cfg.ColorBlue = theme.Blue cfg.ColorMagenta = theme.Magenta - cfg.ColorCyan = theme.Cyan + cfg.ColorAccent = theme.Accent cfg.ColorGray = theme.Gray } else { noop := func(s string) string { return s } @@ -102,7 +102,7 @@ func New() *Config { cfg.ColorBold = noop cfg.ColorBlue = noop cfg.ColorMagenta = noop - cfg.ColorCyan = noop + cfg.ColorAccent = noop cfg.ColorGray = noop } @@ -163,7 +163,7 @@ func (c *Config) Warningf(format string, args ...any) { } func (c *Config) Infof(format string, args ...any) { - fmt.Fprintf(c.Err, "%s %s\n", c.ColorCyan("\u2139"), fmt.Sprintf(format, args...)) + fmt.Fprintf(c.Err, "%s %s\n", c.ColorAccent("\u2139"), fmt.Sprintf(format, args...)) } func (c *Config) Printf(format string, args ...any) { diff --git a/internal/config/testing.go b/internal/config/testing.go index ad3bca26..7d248e93 100644 --- a/internal/config/testing.go +++ b/internal/config/testing.go @@ -26,7 +26,7 @@ func NewTestConfig() (*Config, *os.File, *os.File) { ColorBold: noop, ColorBlue: noop, ColorMagenta: noop, - ColorCyan: noop, + ColorAccent: noop, ColorGray: noop, GitHubClientOverride: &github.MockClient{}, } diff --git a/internal/modify/apply.go b/internal/modify/apply.go index 4aa03dca..03603a5a 100644 --- a/internal/modify/apply.go +++ b/internal/modify/apply.go @@ -664,7 +664,7 @@ func ContinueApply( if needsSubmit { cfg.Printf("") cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub", - cfg.ColorCyan("gh stack submit")) + cfg.ColorAccent("gh stack submit")) } return nil } @@ -680,8 +680,8 @@ func printContinueConflict(cfg *config.Config, state *StateFile, conflict *modif } cfg.Printf("") cfg.Printf("Resolve the conflicts in %s, stage with `%s`, then run `%s`", - path, cfg.ColorCyan("git add "), cfg.ColorCyan("gh stack modify --continue")) - cfg.Printf("Or restore the stack with `%s`", cfg.ColorCyan("gh stack modify --abort")) + path, cfg.ColorAccent("git add "), cfg.ColorAccent("gh stack modify --continue")) + cfg.Printf("Or restore the stack with `%s`", cfg.ColorAccent("gh stack modify --abort")) } // Unwind restores the stack to its pre-modify state using the snapshot. diff --git a/internal/modify/preconditions.go b/internal/modify/preconditions.go index 61aec802..8b13ff94 100644 --- a/internal/modify/preconditions.go +++ b/internal/modify/preconditions.go @@ -191,8 +191,8 @@ func CheckStackLinearity(cfg *config.Config, s *stack.Stack) error { if !isAnc { cfg.Errorf("%s has diverged from %s", b.Branch, parentBranch) cfg.Printf("Run `%s` to normalize the stack, or `%s` to restructure manually", - cfg.ColorCyan("gh stack rebase"), - cfg.ColorCyan("gh stack unstack")) + cfg.ColorAccent("gh stack rebase"), + cfg.ColorAccent("gh stack unstack")) return fmt.Errorf("%s has diverged from %s", b.Branch, parentBranch) } @@ -204,8 +204,8 @@ func CheckStackLinearity(cfg *config.Config, s *stack.Stack) error { if len(merges) > 0 { cfg.Errorf("%s contains a merge commit — modify requires linear history", b.Branch) cfg.Printf("Run `%s` to replay without the merge, or `%s` to restructure manually", - cfg.ColorCyan("gh stack rebase"), - cfg.ColorCyan("gh stack unstack")) + cfg.ColorAccent("gh stack rebase"), + cfg.ColorAccent("gh stack unstack")) return fmt.Errorf("%s contains merge commits", b.Branch) } } diff --git a/internal/theme/canvas.go b/internal/theme/canvas.go new file mode 100644 index 00000000..02130fed --- /dev/null +++ b/internal/theme/canvas.go @@ -0,0 +1,264 @@ +package theme + +import ( + "bytes" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +// BackgroundEnv names the environment variable that controls whether the +// full-screen TUIs paint the GitHub canvas: "github" (the default) paints it, +// and "terminal" keeps the terminal's own background and text colors. +const BackgroundEnv = "GH_STACK_BACKGROUND" + +// canvasReset returns the terminal's default background and foreground to its +// configured colors (OSC 111/110). +const canvasReset = ansi.ResetBackgroundColor + ansi.ResetForegroundColor + +var ( + enterAltScreen = []byte(ansi.SetModeAltScreenSaveCursor) + exitAltScreen = []byte(ansi.ResetModeAltScreenSaveCursor) +) + +// CanvasSupported reports whether the full-screen TUIs should paint the GitHub +// canvas: output is a color-enabled terminal (color not disabled by NO_COLOR or +// CLICOLOR, and not TERM=dumb) that renders truecolor, so the canvas matches the +// palette exactly, and the user hasn't set GH_STACK_BACKGROUND=terminal. +func CanvasSupported(isTTY, colorEnabled bool) bool { + return canvasSupported(os.Getenv(BackgroundEnv), os.Getenv("TERM"), isTTY, colorEnabled, lipgloss.ColorProfile()) +} + +func canvasSupported(background, termName string, isTTY, colorEnabled bool, profile termenv.Profile) bool { + if strings.EqualFold(strings.TrimSpace(background), "terminal") { + return false + } + if !isTTY || !colorEnabled || termName == "dumb" { + return false + } + return profile == termenv.TrueColor +} + +// canvasPaint returns the sequence that paints the GitHub canvas for a dark or +// light background: ColorCanvas as the terminal's default background (OSC 11) +// and ColorText as its default foreground (OSC 10). +func canvasPaint(dark bool) string { + bg, fg := ColorCanvas.Light, ColorText.Light + if dark { + bg, fg = ColorCanvas.Dark, ColorText.Dark + } + return ansi.SetBackgroundColor(bg) + ansi.SetForegroundColor(fg) +} + +// CanvasOutput is the output for a full-screen Bubble Tea program +// (tea.WithOutput) that paints the GitHub canvas while the program is on the +// alternate screen, the way the Copilot CLI does. The canvas is painted right +// after the program enters the alternate screen and taken down right before it +// leaves, so the primary screen never shows it. Bubble Tea leaves the +// alternate screen when the program exits (including on panics and signals) +// and around tea.ExecProcess, which keeps the canvas out of the user's shell, +// scrollback, and $EDITOR. +// +// Resetting (OSC 111/110) returns a terminal to its configured colors, which +// drops colors set at runtime (e.g. by pywal or base16-shell) and does nothing +// on terminals without reset support. So before painting, the output plans how +// to put each color back (see planRestore) and applies that plan every time it +// leaves the alternate screen. +// +// It wraps rather than embeds the *os.File on purpose: Bubble Tea writes escape +// sequences with io.WriteString, which would call the file's WriteString and +// bypass the interception in Write. Because the wrapper is not an *os.File, a +// command run with tea.ExecProcess must set its own Stdout (e.g. os.Stdout); +// otherwise Bubble Tea hands it this output and the command sees a pipe +// instead of a terminal. +type CanvasOutput struct { + f *os.File + paint string + // restore puts the terminal's colors back when the canvas comes down. + restore string + + mu sync.Mutex + painted bool +} + +// colorQuery reports the terminal's current default background and foreground +// as lowercase "#rrggbb", with "" for a color the terminal didn't report. +type colorQuery func() (bg, fg string) + +// NewCanvasOutput wraps f, painting the canvas variant that matches the +// detected (or GH_STACK_THEME-forced) terminal background. It queries the +// terminal to plan the restore, so call it before the program starts reading +// input. +func NewCanvasOutput(f *os.File) *CanvasOutput { + return newCanvasOutput(f, lipgloss.HasDarkBackground(), terminalColors(f)) +} + +func newCanvasOutput(f *os.File, dark bool, query colorQuery) *CanvasOutput { + return &CanvasOutput{f: f, paint: canvasPaint(dark), restore: planRestore(f, query)} +} + +// planRestore decides how to put each of the terminal's default colors back +// when the canvas comes down, while the terminal can still be queried. A reset +// is used when it brings the color back, so configuration changes made while +// the screen is open (e.g. an automatic light/dark switch) still apply. A color +// set at runtime, or any color on a terminal that ignores resets, is set back +// explicitly instead. To tell these apart, planRestore nudges each color +// imperceptibly, resets it, and reads back what the terminal shows, then puts +// back right away any color the reset didn't restore. Without answers from the +// terminal, it falls back to the reset. +func planRestore(w io.Writer, query colorQuery) string { + bg, fg := query() + if bg == "" || fg == "" || isCanvas(bg, fg) { + return canvasReset + } + _, _ = io.WriteString(w, ansi.SetBackgroundColor(nudge(bg))+ansi.SetForegroundColor(nudge(fg))+canvasReset) + resetBg, resetFg := query() + bgPlan := restoreColor(bg, resetBg, ansi.ResetBackgroundColor, ansi.SetBackgroundColor) + fgPlan := restoreColor(fg, resetFg, ansi.ResetForegroundColor, ansi.SetForegroundColor) + if fix := explicit(bgPlan) + explicit(fgPlan); fix != "" { + _, _ = io.WriteString(w, fix) + } + return bgPlan + fgPlan +} + +// restoreColor returns how to restore a color that was before painting and +// reads afterReset once reset: the reset itself when that brought the color +// back (or the terminal stopped answering), otherwise the color, set +// explicitly. +func restoreColor(before, afterReset, reset string, set func(string) string) string { + if afterReset == "" || afterReset == before { + return reset + } + return set(before) +} + +// explicit returns plan when it sets a color explicitly, or "" for a reset. +func explicit(plan string) string { + if plan == ansi.ResetBackgroundColor || plan == ansi.ResetForegroundColor { + return "" + } + return plan +} + +// nudge returns hex with its blue channel shifted by a few units: a color the +// eye can't tell apart from hex, used to see whether a reset takes effect. +func nudge(hex string) string { + if len(hex) != 7 { + return hex + } + b, err := strconv.ParseUint(hex[5:], 16, 8) + if err != nil { + return hex + } + if b >= 0x80 { + b -= 4 + } else { + b += 4 + } + return fmt.Sprintf("%s%02x", hex[:5], b) +} + +// terminalColors queries the terminal behind f for its current default colors +// (OSC 11/10). Terminals that don't answer report "", as do multiplexers and +// Windows consoles, which termenv doesn't query. +func terminalColors(f *os.File) colorQuery { + return func() (string, string) { + out := termenv.NewOutput(f) + return rgbHex(out.BackgroundColor()), rgbHex(out.ForegroundColor()) + } +} + +// rgbHex returns c as lowercase "#rrggbb" when it is the terminal's answer to +// a color query, or "" for termenv's fallback guesses. +func rgbHex(c termenv.Color) string { + if rgb, ok := c.(termenv.RGBColor); ok { + return strings.ToLower(string(rgb)) + } + return "" +} + +// isCanvas reports whether bg and fg are a GitHub canvas left by an earlier +// run that couldn't restore the terminal (e.g. it was killed), which must not +// be put back as the user's own colors. +func isCanvas(bg, fg string) bool { + return (bg == ColorCanvas.Dark && fg == ColorText.Dark) || + (bg == ColorCanvas.Light && fg == ColorText.Light) +} + +// Write forwards p to the terminal, painting the canvas after an +// alternate-screen enter and restoring the terminal's colors before an +// alternate-screen exit. +func (o *CanvasOutput) Write(p []byte) (int, error) { + o.mu.Lock() + defer o.mu.Unlock() + + out := p + if bytes.Contains(p, enterAltScreen) || bytes.Contains(p, exitAltScreen) { + out = o.inject(p) + } + if _, err := o.f.Write(out); err != nil { + return 0, err + } + return len(p), nil +} + +// inject returns p with the canvas paint inserted after each alternate-screen +// enter and the color restore inserted before each alternate-screen exit, so +// each transition and its color change reach the terminal in a single write. +func (o *CanvasOutput) inject(p []byte) []byte { + out := make([]byte, 0, len(p)+len(o.paint)+len(o.restore)) + for { + enter := bytes.Index(p, enterAltScreen) + exit := bytes.Index(p, exitAltScreen) + switch { + case enter >= 0 && (exit < 0 || enter < exit): + end := enter + len(enterAltScreen) + out = append(out, p[:end]...) + if !o.painted { + out = append(out, o.paint...) + o.painted = true + } + p = p[end:] + case exit >= 0: + out = append(out, p[:exit]...) + if o.painted { + out = append(out, o.restore...) + o.painted = false + } + out = append(out, exitAltScreen...) + p = p[exit+len(exitAltScreen):] + default: + return append(out, p...) + } + } +} + +// Restore puts the terminal's colors back if the canvas is still painted. It is +// a safety net for a program that returns without leaving the alternate +// screen; defer it right after creating the output. +func (o *CanvasOutput) Restore() { + o.mu.Lock() + defer o.mu.Unlock() + if !o.painted { + return + } + o.painted = false + _, _ = io.WriteString(o.f, o.restore) +} + +// Read reads from the wrapped file. With Close and Fd, it lets Bubble Tea treat +// the output as a terminal (raw mode, window size). +func (o *CanvasOutput) Read(p []byte) (int, error) { return o.f.Read(p) } + +// Close closes the wrapped file. +func (o *CanvasOutput) Close() error { return o.f.Close() } + +// Fd returns the wrapped file's descriptor. +func (o *CanvasOutput) Fd() uintptr { return o.f.Fd() } diff --git a/internal/theme/canvas_test.go b/internal/theme/canvas_test.go new file mode 100644 index 00000000..9d6d33c5 --- /dev/null +++ b/internal/theme/canvas_test.go @@ -0,0 +1,429 @@ +package theme + +import ( + "io" + "os" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + enter = ansi.SetModeAltScreenSaveCursor + exit = ansi.ResetModeAltScreenSaveCursor +) + +func TestCanvasSupported(t *testing.T) { + tests := []struct { + name string + background string + term string + isTTY bool + colorEnabled bool + profile termenv.Profile + want bool + }{ + {name: "truecolor terminal", term: "xterm-256color", isTTY: true, colorEnabled: true, profile: termenv.TrueColor, want: true}, + {name: "explicit github", background: "github", term: "xterm-256color", isTTY: true, colorEnabled: true, profile: termenv.TrueColor, want: true}, + {name: "unknown value keeps the default", background: "auto", term: "xterm-256color", isTTY: true, colorEnabled: true, profile: termenv.TrueColor, want: true}, + {name: "opted out", background: "terminal", term: "xterm-256color", isTTY: true, colorEnabled: true, profile: termenv.TrueColor}, + {name: "opted out (case and space)", background: " Terminal ", term: "xterm-256color", isTTY: true, colorEnabled: true, profile: termenv.TrueColor}, + {name: "not a terminal", term: "xterm-256color", colorEnabled: true, profile: termenv.TrueColor}, + {name: "color disabled", term: "xterm-256color", isTTY: true, profile: termenv.TrueColor}, + {name: "dumb terminal", term: "dumb", isTTY: true, colorEnabled: true, profile: termenv.TrueColor}, + {name: "256 colors", term: "xterm-256color", isTTY: true, colorEnabled: true, profile: termenv.ANSI256}, + {name: "16 colors", term: "xterm", isTTY: true, colorEnabled: true, profile: termenv.ANSI}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canvasSupported(tt.background, tt.term, tt.isTTY, tt.colorEnabled, tt.profile) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCanvasPaint(t *testing.T) { + assert.Equal(t, "\x1b]11;#0d1117\x07\x1b]10;#f0f6fc\x07", canvasPaint(true), "GitHub dark canvas and ink") + assert.Equal(t, "\x1b]11;#ffffff\x07\x1b]10;#1f2328\x07", canvasPaint(false), "GitHub light canvas and ink") + assert.Equal(t, "\x1b]111\x07\x1b]110\x07", canvasReset) +} + +func TestNewCanvasOutputFollowsBackground(t *testing.T) { + before := lipgloss.HasDarkBackground() + t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) }) + f := tempFile(t) + + lipgloss.SetHasDarkBackground(true) + assert.Equal(t, canvasPaint(true), NewCanvasOutput(f).paint) + lipgloss.SetHasDarkBackground(false) + assert.Equal(t, canvasPaint(false), NewCanvasOutput(f).paint) +} + +func TestCanvasOutputWrite(t *testing.T) { + paint := canvasPaint(true) + tests := []struct { + name string + writes []string + want string + wantPainted bool + }{ + {name: "plain output passes through", writes: []string{"hello"}, want: "hello"}, + {name: "paints after entering", writes: []string{enter}, want: enter + paint, wantPainted: true}, + {name: "paints within a larger write", writes: []string{"a" + enter + "b"}, want: "a" + enter + paint + "b", wantPainted: true}, + {name: "restores before leaving", writes: []string{enter, "frame", exit}, want: enter + paint + "frame" + canvasReset + exit}, + {name: "leaving unpainted adds nothing", writes: []string{exit}, want: exit}, + {name: "repeated enter paints once", writes: []string{enter, enter}, want: enter + paint + enter, wantPainted: true}, + { + name: "release and restore in one write", + writes: []string{enter, "x" + exit + "y" + enter + "z"}, + want: enter + paint + "x" + canvasReset + exit + "y" + enter + paint + "z", + wantPainted: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o, read := testCanvasOutput(t, true) + for _, w := range tt.writes { + n, err := o.Write([]byte(w)) + require.NoError(t, err) + assert.Equal(t, len(w), n, "reports only the caller's bytes") + } + assert.Equal(t, tt.want, read()) + assert.Equal(t, tt.wantPainted, o.painted) + }) + } +} + +// TestCanvasOutputWriteString guards against embedding *os.File: Bubble Tea +// writes escape sequences with io.WriteString, which must still be intercepted. +func TestCanvasOutputWriteString(t *testing.T) { + o, read := testCanvasOutput(t, false) + _, err := io.WriteString(o, enter) + require.NoError(t, err) + assert.Equal(t, enter+canvasPaint(false), read()) +} + +func TestCanvasOutputRestore(t *testing.T) { + o, read := testCanvasOutput(t, true) + o.Restore() + assert.Empty(t, read(), "nothing to restore before painting") + + _, err := o.Write([]byte(enter)) + require.NoError(t, err) + o.Restore() + o.Restore() + assert.Equal(t, enter+canvasPaint(true)+canvasReset, read(), "restores exactly once") + assert.False(t, o.painted) +} + +func TestRGBHex(t *testing.T) { + assert.Equal(t, "#1e1e1e", rgbHex(termenv.RGBColor("#1E1E1E")), "a terminal's answer") + assert.Empty(t, rgbHex(termenv.ANSIColor(0)), "termenv's fallback guess") + assert.Empty(t, rgbHex(nil), "no answer") +} + +func TestNudge(t *testing.T) { + assert.Equal(t, "#1e1e22", nudge("#1e1e1e")) + assert.Equal(t, "#fffffb", nudge("#ffffff"), "moves away from the channel's limit") + assert.Equal(t, "invalid", nudge("invalid")) +} + +// TestPlanRestore checks the restore planned for each kind of terminal color, +// and that planning leaves the terminal showing the colors it had. +func TestPlanRestore(t *testing.T) { + configured := [2]string{"#1e1e1e", "#cccccc"} + runtime := [2]string{"#282a36", "#f8f8f2"} + setRuntime := ansi.SetBackgroundColor(runtime[0]) + ansi.SetForegroundColor(runtime[1]) + tests := []struct { + name string + term fakeTerminal + want string + wantQueries int + wantOverride [2]string + }{ + { + name: "configured colors are reset", + term: fakeTerminal{configured: configured}, + want: canvasReset, + wantQueries: 2, + }, + { + name: "runtime colors are set back", + term: fakeTerminal{configured: configured, override: runtime}, + want: setRuntime, + wantQueries: 2, + wantOverride: runtime, + }, + { + name: "a terminal that ignores resets gets its colors set back", + term: fakeTerminal{configured: configured, ignoresReset: true}, + want: ansi.SetBackgroundColor(configured[0]) + ansi.SetForegroundColor(configured[1]), + wantQueries: 2, + wantOverride: configured, + }, + { + name: "each color is planned on its own", + term: fakeTerminal{configured: configured, override: [2]string{runtime[0], ""}}, + want: ansi.SetBackgroundColor(runtime[0]) + ansi.ResetForegroundColor, + wantQueries: 2, + wantOverride: [2]string{runtime[0], ""}, + }, + { + name: "a terminal that doesn't report colors is reset", + term: fakeTerminal{configured: configured, silent: true}, + want: canvasReset, + wantQueries: 1, + }, + { + name: "a canvas left by a killed run is reset", + term: fakeTerminal{configured: configured, override: [2]string{"#0d1117", "#f0f6fc"}}, + want: canvasReset, + wantQueries: 1, + wantOverride: [2]string{"#0d1117", "#f0f6fc"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + term := tt.term + term.out = tempFile(t) + before := term.colors() + + plan := planRestore(term.out, term.query(t)) + + term.sync(t) + assert.Equal(t, tt.want, plan) + assert.Equal(t, tt.wantQueries, term.queries) + assert.Equal(t, before, term.colors(), "planning leaves the terminal's colors as they were") + assert.Equal(t, tt.wantOverride, term.override, "colors set explicitly") + }) + } +} + +// TestCanvasOutputRestoresTerminalColors runs a real alternate-screen program +// that launches an external command, as submit does for $EDITOR, against a +// fake terminal. It checks the colors the command sees and the colors left +// after the program, including when the configured theme changes while the +// program is open (e.g. an automatic light/dark switch). +func TestCanvasOutputRestoresTerminalColors(t *testing.T) { + dark := [2]string{"#1e1e1e", "#cccccc"} + light := [2]string{"#fdf6e3", "#657b83"} + runtime := [2]string{"#282a36", "#f8f8f2"} + tests := []struct { + name string + term fakeTerminal + themeChange bool // switch the configured colors to light while the command runs + wantDuring [2]string + wantAfter [2]string + wantOverride [2]string + }{ + { + name: "configured colors", + term: fakeTerminal{configured: dark}, + wantDuring: dark, + wantAfter: dark, + }, + { + name: "configured theme changes while open", + term: fakeTerminal{configured: dark}, + themeChange: true, + wantDuring: dark, + wantAfter: light, + }, + { + name: "runtime colors", + term: fakeTerminal{configured: dark, override: runtime}, + wantDuring: runtime, + wantAfter: runtime, + wantOverride: runtime, + }, + { + name: "runtime colors outlast a theme change", + term: fakeTerminal{configured: dark, override: runtime}, + themeChange: true, + wantDuring: runtime, + wantAfter: runtime, + wantOverride: runtime, + }, + { + name: "terminal ignores resets", + term: fakeTerminal{configured: dark, ignoresReset: true}, + wantDuring: dark, + wantAfter: dark, + wantOverride: dark, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + term := tt.term + term.out = tempFile(t) + o := newCanvasOutput(term.out, true, term.query(t)) + planned := term.queries + var during [2]string + probe := &execProbe{out: o, run: func() { + term.sync(t) + during = term.colors() + if tt.themeChange { + term.configured = light + } + }} + model := execModel{cmd: tea.Exec(probe, func(error) tea.Msg { return execDoneMsg{} })} + + _, err := tea.NewProgram(model, + tea.WithAltScreen(), + tea.WithInput(strings.NewReader("")), + tea.WithOutput(o), + tea.WithoutSignalHandler(), + ).Run() + require.NoError(t, err) + o.Restore() + term.sync(t) + + assert.True(t, probe.ran) + assert.False(t, probe.paintedDuringRun, "the canvas is down while the command runs") + assert.Equal(t, tt.wantDuring, during, "colors the command sees") + assert.Equal(t, tt.wantAfter, term.colors(), "colors after the program") + assert.Equal(t, tt.wantOverride, term.override, "colors left set explicitly") + assert.Equal(t, planned, term.queries, "no queries while the program owns the terminal") + assert.Equal(t, 2, strings.Count(readAll(t, term.out)(), canvasPaint(true)), "painted on each alternate-screen enter") + }) + } +} + +type execDoneMsg struct{} + +type execModel struct{ cmd tea.Cmd } + +func (m execModel) Init() tea.Cmd { return m.cmd } + +func (m execModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(execDoneMsg); ok { + return m, tea.Quit + } + return m, nil +} + +func (m execModel) View() string { return "canvas" } + +// execProbe is a tea.ExecCommand that records whether the canvas was painted +// while it ran, then calls run. +type execProbe struct { + out *CanvasOutput + run func() + ran bool + paintedDuringRun bool +} + +func (p *execProbe) Run() error { + p.out.mu.Lock() + p.ran, p.paintedDuringRun = true, p.out.painted + p.out.mu.Unlock() + if p.run != nil { + p.run() + } + return nil +} + +func (p *execProbe) SetStdin(io.Reader) {} +func (p *execProbe) SetStdout(io.Writer) {} +func (p *execProbe) SetStderr(io.Writer) {} + +// fakeTerminal models a terminal's default colors: its configured colors, +// runtime overrides set with OSC 10/11, and resets (OSC 110/111) that clear an +// override unless the terminal ignores them. Before answering a color query or +// reporting its state, it applies everything written to out since last time. +type fakeTerminal struct { + out *os.File + read int + configured [2]string // background, foreground + override [2]string // "" when not set + ignoresReset bool + silent bool // answers no color queries + queries int +} + +func (ft *fakeTerminal) query(t *testing.T) colorQuery { + return func() (string, string) { + ft.sync(t) + ft.queries++ + if ft.silent { + return "", "" + } + c := ft.colors() + return c[0], c[1] + } +} + +func (ft *fakeTerminal) colors() [2]string { + c := ft.configured + for i, o := range ft.override { + if o != "" { + c[i] = o + } + } + return c +} + +func (ft *fakeTerminal) sync(t *testing.T) { + t.Helper() + b, err := os.ReadFile(ft.out.Name()) + require.NoError(t, err) + s := string(b[ft.read:]) + ft.read = len(b) + for { + start := strings.Index(s, "\x1b]") + if start < 0 { + return + } + s = s[start+2:] + end := strings.IndexAny(s, "\x07\x1b") + if end < 0 { + return + } + ft.apply(s[:end]) + s = s[end:] + } +} + +func (ft *fakeTerminal) apply(osc string) { + ps, pt, _ := strings.Cut(osc, ";") + switch ps { + case "11": + ft.override[0] = pt + case "10": + ft.override[1] = pt + case "111", "110": + if !ft.ignoresReset { + ft.override[map[string]int{"111": 0, "110": 1}[ps]] = "" + } + } +} + +// testCanvasOutput returns a CanvasOutput that resets the terminal's colors, +// over a temporary file, and a func that reads everything written to it. +func testCanvasOutput(t *testing.T, dark bool) (*CanvasOutput, func() string) { + t.Helper() + f := tempFile(t) + return &CanvasOutput{f: f, paint: canvasPaint(dark), restore: canvasReset}, readAll(t, f) +} + +func readAll(t *testing.T, f *os.File) func() string { + return func() string { + b, err := os.ReadFile(f.Name()) + require.NoError(t, err) + return string(b) + } +} + +func tempFile(t *testing.T) *os.File { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "canvas") + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + return f +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go index c23f7814..d30ebbc3 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -1,19 +1,27 @@ -// 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). +// Package theme defines gh-stack's GitHub-branded, background-aware color +// palette and the helpers that render it, shared by both the interactive TUIs +// and ordinary command output (prompts, status messages). +// +// The palette follows the GitHub Copilot CLI's "github" theme. Neutrals (text, +// borders, and the canvas) are GitHub's own interface colors, and the chromatic +// roles (accent, PR states, status messages) use the vivid Primer Brand scales +// (https://primer.style/brand), choosing the step on each scale that keeps WCAG +// AA contrast on GitHub's dark (#0d1117) and light (#ffffff) canvases. The +// full-screen TUIs paint that canvas behind themselves (see CanvasOutput); all +// other output keeps the user's terminal background. // // 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. +// answer the query fall back to the dark palette. // 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. +// Values are truecolor hex 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 ( @@ -23,9 +31,15 @@ import ( "github.com/charmbracelet/lipgloss" ) +// Neutrals: GitHub's interface colors (Primer fgColor/borderColor/bgColor). var ( + // ColorCanvas is GitHub's page background. The full-screen TUIs paint it as + // the terminal's default background (see CanvasOutput). + ColorCanvas = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} + // ColorText is primary/emphasis ink: titles, branch names, links, active - // keys, the description scrollbar thumb. + // keys, the description scrollbar thumb. The full-screen TUIs paint it as + // the terminal's default foreground alongside ColorCanvas. ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} // ColorTextMuted is secondary ink and dim chrome text: section labels, // shortcut descriptions, hints, trunk/merged branches, timestamps. @@ -42,31 +56,89 @@ var ( // 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"} + // ColorCodeBg is the chip behind inline code in rendered markdown (Primer + // neutral.muted, matching the Copilot CLI's inline-code chip). + ColorCodeBg = lipgloss.AdaptiveColor{Dark: "#262c36", Light: "#e8e9eb"} +) - // 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"} +// Brand colors: Primer Brand scale steps (dark scale / light scale). +var ( + // ColorAccent is the brand blue for interactive emphasis: the current or + // focused branch, keyboard shortcut keys, footer accents, spinners, links, + // and commands called out in plain command output. Primer Brand blue-3 (the + // brand focus color) on dark, and the brand link color blue-6 on light. + ColorAccent = lipgloss.AdaptiveColor{Dark: "#3094ff", Light: "#0055d5"} // 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 + ColorBlue = ColorAccent // NEW + ColorGreen = lipgloss.AdaptiveColor{Dark: "#5fed83", Light: "#08872b"} // OPEN, additions, success: green-2 / green-6 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"} + ColorYellow = lipgloss.AdaptiveColor{Dark: "#db9d00", Light: "#a06100"} // QUEUED, warning, commit SHA: yellow-3 / yellow-6 + ColorPurple = lipgloss.AdaptiveColor{Dark: "#a665f9", Light: "#8534f3"} // MERGED, magenta: purple-3 / purple-5 + ColorRed = lipgloss.AdaptiveColor{Dark: "#fc5c5d", Light: "#cf2230"} // CLOSED, deletions, error: red-3 / red-5 + + // Dim status colors blend the PR-state hues 45% toward ColorTextFaint for + // low-emphasis indicators (the checkout status bar), so they stay legible + // without pulling focus from surrounding text. + ColorPurpleDim = lipgloss.AdaptiveColor{Dark: "#8968be", Light: "#835bca"} + ColorGreenDim = lipgloss.AdaptiveColor{Dark: "#62b37d", Light: "#3e895c"} + ColorRedDim = lipgloss.AdaptiveColor{Dark: "#b86368", Light: "#ac515f"} +) + +// Fills: solid backgrounds and the ink drawn on them. +var ( + // ColorPrimaryFill and ColorOnPrimary style primary actions (the submit + // button, the selected Ready/Draft segment, an enabled switch, completed + // merge steps) after Primer Brand's primary button: white on green in both + // modes. + ColorPrimaryFill = lipgloss.Color("#08872b") + ColorOnPrimary = lipgloss.Color("#ffffff") + + // ColorOnInverse is ink drawn on a ColorText fill (e.g. the merge wizard's + // current step): the canvas color, so the text reads as cut out of the fill. + ColorOnInverse = ColorCanvas + + // ColorToggleOffTrack and ColorToggleOffKnob draw a switch in its off state. + ColorToggleOffTrack = lipgloss.AdaptiveColor{Dark: "#6e7681", Light: "#afb8c1"} + ColorToggleOffKnob = lipgloss.AdaptiveColor{Dark: "#1c2128", Light: "#57606a"} +) + +// Badge is the color pair for a status pill: a state color on a faint wash of +// the same hue (12% of the hue over the canvas). On light backgrounds the text +// is one Primer Brand step deeper than the plain state color so every pill +// keeps AA contrast against its wash. +type Badge struct { + Fg lipgloss.AdaptiveColor + Bg lipgloss.AdaptiveColor +} + +var ( + BadgeBlue = Badge{ + Fg: ColorBlue, + Bg: lipgloss.AdaptiveColor{Dark: "#112133", Light: "#e0ebfa"}, + } + BadgeGreen = Badge{ + Fg: lipgloss.AdaptiveColor{Dark: "#5fed83", Light: "#0d6731"}, + Bg: lipgloss.AdaptiveColor{Dark: "#172b24", Light: "#e1f1e6"}, + } + BadgeGray = Badge{ + Fg: ColorGray, + Bg: lipgloss.AdaptiveColor{Dark: "#1d2128", Light: "#ebecee"}, + } + BadgeYellow = Badge{ + Fg: lipgloss.AdaptiveColor{Dark: "#db9d00", Light: "#834800"}, + Bg: lipgloss.AdaptiveColor{Dark: "#262214", Light: "#f4ece0"}, + } + BadgePurple = Badge{ + Fg: lipgloss.AdaptiveColor{Dark: "#a665f9", Light: "#6619e1"}, + Bg: lipgloss.AdaptiveColor{Dark: "#1f1b32", Light: "#f0e7fe"}, + } + BadgeRed = Badge{ + Fg: lipgloss.AdaptiveColor{Dark: "#fc5c5d", Light: "#ae0b29"}, + Bg: lipgloss.AdaptiveColor{Dark: "#2a1a1f", Light: "#f9e4e6"}, + } ) // ApplyOverride honors the GH_STACK_THEME environment variable, forcing the @@ -114,8 +186,8 @@ 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) } +// Accent renders s in the accent (brand blue) color. +func Accent(s string) string { return Colorize(ColorAccent, s) } // Gray renders s in the muted gray color. func Gray(s string) string { return Colorize(ColorGray, s) } diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go index ba6809c7..ef86c66a 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -1,7 +1,9 @@ package theme import ( + "fmt" "io" + "math" "strings" "testing" @@ -16,14 +18,30 @@ import ( // renderer with a color-capable profile so it doesn't mutate global state. func TestPaletteIsBackgroundAware(t *testing.T) { colors := map[string]lipgloss.AdaptiveColor{ - "text": ColorText, - "textMuted": ColorTextMuted, - "accent": ColorAccent, - "green": ColorGreen, - "red": ColorRed, - "yellow": ColorYellow, - "blue": ColorBlue, - "purple": ColorPurple, + "canvas": ColorCanvas, + "text": ColorText, + "textMuted": ColorTextMuted, + "textFaint": ColorTextFaint, + "border": ColorBorder, + "rowShade": ColorRowShade, + "codeBg": ColorCodeBg, + "accent": ColorAccent, + "green": ColorGreen, + "red": ColorRed, + "yellow": ColorYellow, + "blue": ColorBlue, + "purple": ColorPurple, + "gray": ColorGray, + "purpleDim": ColorPurpleDim, + "greenDim": ColorGreenDim, + "redDim": ColorRedDim, + "onInverse": ColorOnInverse, + "toggleOffTrack": ColorToggleOffTrack, + "toggleOffKnob": ColorToggleOffKnob, + } + for name, badge := range badges() { + colors[name+"Fg"] = badge.Fg + colors[name+"Bg"] = badge.Bg } for name, c := range colors { @@ -41,6 +59,92 @@ func TestPaletteIsBackgroundAware(t *testing.T) { } } +// TestPaletteContrast guards legibility on the GitHub canvas the full-screen +// TUIs paint (and that plain output assumes): text roles meet WCAG AA (4.5:1), +// while disabled text, non-text chrome, and bold labels on the focused-row +// shade meet the 3:1 minimum for large text and UI components. +func TestPaletteContrast(t *testing.T) { + type pair struct { + name string + fg, bg lipgloss.AdaptiveColor + min float64 + } + pairs := []pair{ + {"text", ColorText, ColorCanvas, 4.5}, + {"textMuted", ColorTextMuted, ColorCanvas, 4.5}, + {"textFaint", ColorTextFaint, ColorCanvas, 3}, + {"accent", ColorAccent, ColorCanvas, 4.5}, + {"green", ColorGreen, ColorCanvas, 4.5}, + {"gray", ColorGray, ColorCanvas, 4.5}, + {"yellow", ColorYellow, ColorCanvas, 4.5}, + {"purple", ColorPurple, ColorCanvas, 4.5}, + {"red", ColorRed, ColorCanvas, 4.5}, + {"onInverse on text", ColorOnInverse, ColorText, 4.5}, + {"text on code chip", ColorText, ColorCodeBg, 4.5}, + {"text on row shade", ColorText, ColorRowShade, 4.5}, + {"accent on row shade", ColorAccent, ColorRowShade, 3}, + {"green on row shade", ColorGreen, ColorRowShade, 3}, + {"yellow on row shade", ColorYellow, ColorRowShade, 3}, + {"purple on row shade", ColorPurple, ColorRowShade, 3}, + {"red on row shade", ColorRed, ColorRowShade, 3}, + {"toggle knob on track", ColorToggleOffKnob, ColorToggleOffTrack, 3}, + } + for name, badge := range badges() { + pairs = append(pairs, pair{"badge " + name, badge.Fg, badge.Bg, 4.5}) + } + + for _, p := range pairs { + t.Run(p.name, func(t *testing.T) { + assert.GreaterOrEqual(t, contrast(t, p.fg.Dark, p.bg.Dark), p.min, "dark") + assert.GreaterOrEqual(t, contrast(t, p.fg.Light, p.bg.Light), p.min, "light") + }) + } + + t.Run("primary fill", func(t *testing.T) { + fill, ink := string(ColorPrimaryFill), string(ColorOnPrimary) + assert.GreaterOrEqual(t, contrast(t, ink, fill), 4.5, "label on the fill") + assert.GreaterOrEqual(t, contrast(t, fill, ColorCanvas.Dark), 3.0, "fill on the dark canvas") + assert.GreaterOrEqual(t, contrast(t, fill, ColorCanvas.Light), 3.0, "fill on the light canvas") + }) +} + +func badges() map[string]Badge { + return map[string]Badge{ + "blue": BadgeBlue, + "green": BadgeGreen, + "gray": BadgeGray, + "yellow": BadgeYellow, + "purple": BadgePurple, + "red": BadgeRed, + } +} + +// contrast returns the WCAG 2 contrast ratio between two #rrggbb colors. +func contrast(t *testing.T, a, b string) float64 { + t.Helper() + la, lb := luminance(t, a), luminance(t, b) + if la < lb { + la, lb = lb, la + } + return (la + 0.05) / (lb + 0.05) +} + +// luminance returns the WCAG 2 relative luminance of a #rrggbb color. +func luminance(t *testing.T, hex string) float64 { + t.Helper() + var r, g, b uint8 + _, err := fmt.Sscanf(strings.TrimPrefix(hex, "#"), "%02x%02x%02x", &r, &g, &b) + require.NoError(t, err, "parsing %q", hex) + linear := func(c uint8) float64 { + v := float64(c) / 255 + if v <= 0.03928 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) + } + return 0.2126*linear(r) + 0.7152*linear(g) + 0.0722*linear(b) +} + func TestApplyOverride(t *testing.T) { // ApplyOverride mutates the default renderer; restore it afterwards. before := lipgloss.HasDarkBackground() @@ -99,7 +203,7 @@ func TestColorizersAreBackgroundAware(t *testing.T) { "Warning": Warning, "Blue": Blue, "Magenta": Magenta, - "Cyan": Cyan, + "Accent": Accent, "Gray": Gray, } for name, fn := range fns { diff --git a/internal/tui/checkoutview/styles.go b/internal/tui/checkoutview/styles.go index 5b3bec20..2f48d70c 100644 --- a/internal/tui/checkoutview/styles.go +++ b/internal/tui/checkoutview/styles.go @@ -27,9 +27,9 @@ const statusBox = "▆" // color toward the faint text gray, so the bar stays legible but understated in // both light and dark terminals. var ( - statusMergedColor = lipgloss.AdaptiveColor{Dark: "#957dc1", Light: "#816abf"} // muted purple - statusOpenColor = lipgloss.AdaptiveColor{Dark: "#509661", Light: "#488462"} // muted green - statusClosedColor = lipgloss.AdaptiveColor{Dark: "#b55d5d", Light: "#ab515d"} // muted red + statusMergedColor = shared.ColorPurpleDim + statusOpenColor = shared.ColorGreenDim + statusClosedColor = shared.ColorRedDim ) var ( diff --git a/internal/tui/mergeview/model_test.go b/internal/tui/mergeview/model_test.go index f34cbdc8..a5e0448a 100644 --- a/internal/tui/mergeview/model_test.go +++ b/internal/tui/mergeview/model_test.go @@ -12,6 +12,8 @@ import ( "github.com/muesli/termenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/github/gh-stack/internal/theme" ) func baseOptions() Options { @@ -534,3 +536,15 @@ func TestPRCount(t *testing.T) { assert.Equal(t, "2 PRs", prCount(2)) assert.Equal(t, "5 PRs", prCount(5)) } + +// TestStepperColors verifies completed steps use the primary (green) fill with +// white labels, the active step is inverted ink, and upcoming steps are muted. +func TestStepperColors(t *testing.T) { + const cur = 1 + assert.Equal(t, lipgloss.TerminalColor(theme.ColorPrimaryFill), stepBg(0, cur)) + assert.Equal(t, lipgloss.TerminalColor(theme.ColorOnPrimary), stepFg(0, cur)) + assert.Equal(t, lipgloss.TerminalColor(theme.ColorText), stepBg(cur, cur)) + assert.Equal(t, lipgloss.TerminalColor(theme.ColorOnInverse), stepFg(cur, cur)) + assert.Equal(t, lipgloss.TerminalColor(theme.ColorBorder), stepBg(2, cur)) + assert.Equal(t, lipgloss.TerminalColor(theme.ColorTextMuted), stepFg(2, cur)) +} diff --git a/internal/tui/mergeview/view.go b/internal/tui/mergeview/view.go index 2faccf06..f9c823b7 100644 --- a/internal/tui/mergeview/view.go +++ b/internal/tui/mergeview/view.go @@ -84,12 +84,13 @@ func (m Model) banner() string { } // stepBg returns the background color for the step at index i given the current -// active step: completed steps are green, the active step is the brightest -// (near-white on dark, near-black on light), and upcoming steps are a dim gray. +// active step: completed steps use the primary (green) fill, the active step is +// the brightest (near-white on dark, near-black on light), and upcoming steps +// are a dim gray. func stepBg(i, cur int) lipgloss.TerminalColor { switch { case i < cur: - return theme.ColorGreen + return theme.ColorPrimaryFill case i == cur: return theme.ColorText default: @@ -97,13 +98,18 @@ func stepBg(i, cur int) lipgloss.TerminalColor { } } -// stepFg returns the foreground color for the step at index i: dark text on the -// bright/green segments, and a dim muted text on the upcoming gray segments. +// stepFg returns the foreground color for the step at index i: white on the +// completed green segments, canvas-colored ink on the bright active segment, +// and muted text on the upcoming gray segments. func stepFg(i, cur int) lipgloss.TerminalColor { - if i > cur { + switch { + case i < cur: + return theme.ColorOnPrimary + case i == cur: + return theme.ColorOnInverse + default: return theme.ColorTextMuted } - return theme.ColorOnFill } func (m Model) stepper() string { diff --git a/internal/tui/shared/assets/invertocat-black.png b/internal/tui/shared/assets/invertocat-black.png new file mode 100644 index 00000000..106ba91e Binary files /dev/null and b/internal/tui/shared/assets/invertocat-black.png differ diff --git a/internal/tui/shared/header.go b/internal/tui/shared/header.go index fa36a50d..2c46b8b7 100644 --- a/internal/tui/shared/header.go +++ b/internal/tui/shared/header.go @@ -38,7 +38,7 @@ type HeaderInfoLine struct { Label string LinkLabel string // optional clickable text within Label URL string // native-click target for LinkLabel - IconStyle *lipgloss.Style // optional override; nil uses default HeaderInfoStyle (cyan) + IconStyle *lipgloss.Style // optional override; nil uses default HeaderInfoStyle (accent) } const headerInfoStartRow = 3 diff --git a/internal/tui/shared/header_test.go b/internal/tui/shared/header_test.go index f851eca3..5cb48a7d 100644 --- a/internal/tui/shared/header_test.go +++ b/internal/tui/shared/header_test.go @@ -1,6 +1,7 @@ package shared import ( + "encoding/base64" "strings" "testing" @@ -94,3 +95,17 @@ func TestHeaderLinkPreservesLayout(t *testing.T) { assert.Equal(t, lipgloss.Width(renderHeaderInfoLabel(plain)), lipgloss.Width(renderHeaderInfoLabel(linked))) assert.NotContains(t, renderHeaderInfoLabel(linked), linked.URL, "native links stay compact") } + +// TestLogoMatchesBackground verifies the header draws the white Invertocat on +// dark backgrounds and the dark mark on light ones, so it stays visible on +// either GitHub canvas. +func TestLogoMatchesBackground(t *testing.T) { + before := lipgloss.HasDarkBackground() + t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) }) + + lipgloss.SetHasDarkBackground(true) + assert.Equal(t, base64.StdEncoding.EncodeToString(invertocatWhitePNG), pngBase64()) + lipgloss.SetHasDarkBackground(false) + assert.Equal(t, base64.StdEncoding.EncodeToString(invertocatBlackPNG), pngBase64()) + assert.NotEqual(t, invertocatWhitePNG, invertocatBlackPNG) +} diff --git a/internal/tui/shared/logo.go b/internal/tui/shared/logo.go index 9a8add7a..2b18eb98 100644 --- a/internal/tui/shared/logo.go +++ b/internal/tui/shared/logo.go @@ -9,15 +9,20 @@ import ( "sync" "github.com/BourgeoisBear/rasterm" + "github.com/charmbracelet/lipgloss" "golang.org/x/term" ) -// invertocatPNG is the white GitHub Invertocat mark, embedded so it never has to -// be fetched at runtime. It is drawn via an inline-image protocol; there is no -// character-art fallback. +// The GitHub Invertocat marks are embedded so they never have to be fetched at +// runtime: the white mark for dark backgrounds and the dark mark (the official +// github-mark color, #24292f) for light ones. They are drawn via an +// inline-image protocol; there is no character-art fallback. // //go:embed assets/invertocat-white.png -var invertocatPNG []byte +var invertocatWhitePNG []byte + +//go:embed assets/invertocat-black.png +var invertocatBlackPNG []byte // headerLogoID is a fixed kitty image id so re-emitting the header replaces the // logo in place (and lets us delete it by id) instead of stacking copies. @@ -41,10 +46,11 @@ const ( ) var ( - logoDetectOnce sync.Once - logoProtocol logoProto - logoBase64 string - logoBase64Once sync.Once + logoDetectOnce sync.Once + logoProtocol logoProto + logoBase64Once sync.Once + logoBase64White string + logoBase64Black string ) // detectLogoProtocol decides — once, at first use — whether an inline-image @@ -71,11 +77,17 @@ func detectLogoProtocol() logoProto { return logoProtocol } +// pngBase64 returns the base64-encoded mark that contrasts with the detected +// terminal background. func pngBase64() string { logoBase64Once.Do(func() { - logoBase64 = base64.StdEncoding.EncodeToString(invertocatPNG) + logoBase64White = base64.StdEncoding.EncodeToString(invertocatWhitePNG) + logoBase64Black = base64.StdEncoding.EncodeToString(invertocatBlackPNG) }) - return logoBase64 + if lipgloss.HasDarkBackground() { + return logoBase64White + } + return logoBase64Black } // LogoAvailable reports whether the inline-image logo can be drawn (a supported diff --git a/internal/tui/shared/render.go b/internal/tui/shared/render.go index 94912a1e..231d0a65 100644 --- a/internal/tui/shared/render.go +++ b/internal/tui/shared/render.go @@ -28,7 +28,7 @@ type BranchNodeData struct { CommitsExpanded bool FilesExpanded bool - // ShowCurrentLabel controls whether "(current)" is appended and cyan + // ShowCurrentLabel controls whether "(current)" is appended and accent // styling is used for the current branch. View sets this true; Modify // leaves it false so all branches look uniform. ShowCurrentLabel bool diff --git a/internal/tui/shared/theme.go b/internal/tui/shared/theme.go index a0c39770..b05165f9 100644 --- a/internal/tui/shared/theme.go +++ b/internal/tui/shared/theme.go @@ -6,19 +6,38 @@ import "github.com/github/gh-stack/internal/theme" // by both the TUIs and ordinary command output. These aliases keep the TUI code // referring to shared.ColorX. var ( - ColorText = theme.ColorText - ColorTextMuted = theme.ColorTextMuted - ColorTextFaint = theme.ColorTextFaint - ColorBorder = theme.ColorBorder - ColorRowShade = theme.ColorRowShade - ColorAccent = theme.ColorAccent - ColorBlue = theme.ColorBlue - ColorGreen = theme.ColorGreen - ColorGray = theme.ColorGray - ColorYellow = theme.ColorYellow - ColorPurple = theme.ColorPurple - ColorRed = theme.ColorRed - ColorOnFill = theme.ColorOnFill - ColorButtonBg = theme.ColorButtonBg - ColorButtonFg = theme.ColorButtonFg + ColorCanvas = theme.ColorCanvas + ColorText = theme.ColorText + ColorTextMuted = theme.ColorTextMuted + ColorTextFaint = theme.ColorTextFaint + ColorBorder = theme.ColorBorder + ColorRowShade = theme.ColorRowShade + ColorCodeBg = theme.ColorCodeBg + ColorAccent = theme.ColorAccent + ColorBlue = theme.ColorBlue + ColorGreen = theme.ColorGreen + ColorGray = theme.ColorGray + ColorYellow = theme.ColorYellow + ColorPurple = theme.ColorPurple + ColorRed = theme.ColorRed + ColorPurpleDim = theme.ColorPurpleDim + ColorGreenDim = theme.ColorGreenDim + ColorRedDim = theme.ColorRedDim + ColorPrimaryFill = theme.ColorPrimaryFill + ColorOnPrimary = theme.ColorOnPrimary + ColorOnInverse = theme.ColorOnInverse + ColorToggleOffTrack = theme.ColorToggleOffTrack + ColorToggleOffKnob = theme.ColorToggleOffKnob +) + +// Badge is the color pair for a status pill (see theme.Badge). +type Badge = theme.Badge + +var ( + BadgeBlue = theme.BadgeBlue + BadgeGreen = theme.BadgeGreen + BadgeGray = theme.BadgeGray + BadgeYellow = theme.BadgeYellow + BadgePurple = theme.BadgePurple + BadgeRed = theme.BadgeRed ) diff --git a/internal/tui/submitview/editor.go b/internal/tui/submitview/editor.go index 3e3e62d1..7e0382c0 100644 --- a/internal/tui/submitview/editor.go +++ b/internal/tui/submitview/editor.go @@ -146,7 +146,7 @@ func (m Model) renderIncludeChip(n SubmitNode) string { // renderSwitch draws a two-state pill toggle with a square knob that slides // between the ends, kept one cell off each border so it never touches the edge: -// " ■ " (green track, black knob right) when on, " ■ " (light track, dark +// " ■ " (green track, white knob right) when on, " ■ " (muted track, dark // knob left) when off. func renderSwitch(on bool) string { if on { @@ -552,8 +552,9 @@ func (m Model) renderDraftToggle(innerW int) string { return composeLR(left, hintStyle.Render("open in $EDITOR (^e)"), innerW) } -// sectionLabelFor renders a field's section label, turning it cyan when the -// field is focused (matching the focused branch name and CREATE AS treatment). +// sectionLabelFor renders a field's section label, turning it the accent color +// when the field is focused (matching the focused branch name and CREATE AS +// treatment). func sectionLabelFor(text string, focused bool) string { if focused { return focusNameStyle.Render(text) diff --git a/internal/tui/submitview/preview.go b/internal/tui/submitview/preview.go index 797a9287..998ce782 100644 --- a/internal/tui/submitview/preview.go +++ b/internal/tui/submitview/preview.go @@ -7,8 +7,12 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" + gansi "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/github/gh-stack/internal/tui/shared" ) // editorFinishedMsg is delivered after the external $EDITOR process exits. @@ -58,12 +62,22 @@ func (m Model) openEditor() (tea.Model, tea.Cmd) { return m, nil } + return m, tea.ExecProcess(editorCommand(editor, path), func(err error) tea.Msg { + return editorFinishedMsg{path: path, err: err} + }) +} + +// editorCommand builds the $EDITOR invocation for path. Its stdout is pinned +// to the real terminal: tea.ExecProcess would otherwise hand the editor the +// program's output, which is the canvas-painting wrapper (theme.CanvasOutput) +// when the canvas is on, so the editor would see a pipe instead of a terminal. +// Stdin is left unset so Bubble Tea passes its own terminal input. +func editorCommand(editor, path string) *exec.Cmd { fields := strings.Fields(editor) args := append(fields[1:], path) cmd := exec.Command(fields[0], args...) - return m, tea.ExecProcess(cmd, func(err error) tea.Msg { - return editorFinishedMsg{path: path, err: err} - }) + cmd.Stdout = os.Stdout + return cmd } // handleEditorFinished reloads the description from the temp file after the @@ -120,13 +134,13 @@ func writeTempDescription(content string) (string, error) { return f.Name(), nil } -// renderMarkdown renders markdown to styled terminal output using Glamour. It -// selects the light or dark Glamour style from the already-detected terminal -// background (lipgloss.HasDarkBackground, cached at startup) rather than -// glamour.WithAutoStyle(): auto-style probes the terminal with an OSC query whose -// response is consumed by Bubble Tea's own input reader, so the query blocks -// forever and freezes the UI. On any error it falls back to the raw markdown so -// the user still sees their content. +// renderMarkdown renders markdown to styled terminal output using Glamour, with +// the gh-stack palette (see markdownStyle). It selects the light or dark style +// from the already-detected terminal background (lipgloss.HasDarkBackground, +// cached at startup) rather than glamour.WithAutoStyle(): auto-style probes the +// terminal with an OSC query whose response is consumed by Bubble Tea's own +// input reader, so the query blocks forever and freezes the UI. On any error it +// falls back to the raw markdown so the user still sees their content. func renderMarkdown(md string, width int) string { if strings.TrimSpace(md) == "" { return hintStyle.Render("(no description)") @@ -134,19 +148,11 @@ func renderMarkdown(md string, width int) string { if width < 10 { width = 10 } - // Match the preview to the terminal background, then drop the document - // block's default 2-column margin so the preview text aligns flush-left with - // the edit-mode textarea instead of being indented. Copying the struct and - // replacing the Margin pointer leaves the shared package-level style - // untouched. - style := styles.DarkStyleConfig - if !lipgloss.HasDarkBackground() { - style = styles.LightStyleConfig - } - var noMargin uint - style.Document.Margin = &noMargin + profile := lipgloss.ColorProfile() r, err := glamour.NewTermRenderer( - glamour.WithStyles(style), + glamour.WithStyles(markdownStyle(lipgloss.HasDarkBackground())), + glamour.WithColorProfile(profile), + glamour.WithChromaFormatter(chromaFormatter(profile)), glamour.WithWordWrap(width), ) if err != nil { @@ -158,3 +164,68 @@ func renderMarkdown(md string, width int) string { } return strings.Trim(out, "\n") } + +// markdownStyle returns Glamour's light or dark base style recolored to the +// gh-stack palette, so the preview reads like a description rendered on GitHub: +// primary ink for body text and headings, accent links, inline code on a +// neutral chip, muted quotes and rules, and GitHub's own syntax highlighting +// for code blocks. The base style is copied, so the shared package-level +// Glamour styles stay untouched. +func markdownStyle(dark bool) gansi.StyleConfig { + style := styles.DarkStyleConfig + codeTheme := "github-dark" + if !dark { + style = styles.LightStyleConfig + codeTheme = "github" + } + pick := func(c lipgloss.AdaptiveColor) *string { + v := c.Light + if dark { + v = c.Dark + } + return &v + } + text, muted, accent := pick(shared.ColorText), pick(shared.ColorTextMuted), pick(shared.ColorAccent) + + // Drop the document block's default 2-column margin so the preview text + // aligns flush-left with the edit-mode textarea instead of being indented. + var noMargin uint + style.Document.Margin = &noMargin + style.Document.Color = text + + // Headings in bold primary ink, with H1 marked like the other levels rather + // than Glamour's colored pill. GitHub renders H6 in muted ink. + style.Heading.Color = text + style.H1.Prefix, style.H1.Suffix = "# ", "" + style.H1.Color, style.H1.BackgroundColor = nil, nil + style.H6.Color = muted + + style.Link.Color = accent + style.LinkText.Color = accent + style.Image.Color = accent + style.ImageText.Color = muted + style.BlockQuote.Color = muted + style.HorizontalRule.Color = pick(shared.ColorBorder) + + style.Code.Color = text + style.Code.BackgroundColor = pick(shared.ColorCodeBg) + style.CodeBlock.Chroma = nil + style.CodeBlock.Theme = codeTheme + return style +} + +// chromaFormatter returns the Chroma formatter that matches the terminal's +// color profile, so highlighted code blocks use exact GitHub syntax colors on +// truecolor terminals and no color at all when color is disabled. +func chromaFormatter(profile termenv.Profile) string { + switch profile { + case termenv.TrueColor: + return "terminal16m" + case termenv.ANSI256: + return "terminal256" + case termenv.ANSI: + return "terminal16" + default: + return "noop" + } +} diff --git a/internal/tui/submitview/preview_test.go b/internal/tui/submitview/preview_test.go index 6d6f9b39..3a06d68b 100644 --- a/internal/tui/submitview/preview_test.go +++ b/internal/tui/submitview/preview_test.go @@ -8,8 +8,13 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/github/gh-stack/internal/tui/shared" ) func TestTogglePreview(t *testing.T) { @@ -156,3 +161,68 @@ func TestPreview_RendersWithoutHanging(t *testing.T) { out := m.View() assert.Contains(t, out, "Heading") } + +func TestMarkdownStyle(t *testing.T) { + for _, dark := range []bool{true, false} { + pick := func(c lipgloss.AdaptiveColor) string { + if dark { + return c.Dark + } + return c.Light + } + style := markdownStyle(dark) + + require.NotNil(t, style.Document.Margin) + assert.Zero(t, *style.Document.Margin, "flush with the edit-mode textarea") + assert.Equal(t, pick(shared.ColorText), *style.Document.Color) + assert.Equal(t, pick(shared.ColorText), *style.Heading.Color) + assert.Equal(t, "# ", style.H1.Prefix, "H1 is marked like the other heading levels") + assert.Nil(t, style.H1.BackgroundColor, "no colored H1 pill") + assert.Equal(t, pick(shared.ColorAccent), *style.Link.Color) + assert.Equal(t, pick(shared.ColorAccent), *style.LinkText.Color) + assert.Equal(t, pick(shared.ColorCodeBg), *style.Code.BackgroundColor) + assert.Nil(t, style.CodeBlock.Chroma, "code blocks use a named Chroma theme") + } + assert.Equal(t, "github-dark", markdownStyle(true).CodeBlock.Theme) + assert.Equal(t, "github", markdownStyle(false).CodeBlock.Theme) + + // The base styles are copied, never modified. + assert.Equal(t, " ", styles.DarkStyleConfig.H1.Prefix) + assert.NotNil(t, styles.DarkStyleConfig.H1.BackgroundColor) + assert.NotNil(t, styles.LightStyleConfig.CodeBlock.Chroma) +} + +func TestChromaFormatter(t *testing.T) { + assert.Equal(t, "terminal16m", chromaFormatter(termenv.TrueColor)) + assert.Equal(t, "terminal256", chromaFormatter(termenv.ANSI256)) + assert.Equal(t, "terminal16", chromaFormatter(termenv.ANSI)) + assert.Equal(t, "noop", chromaFormatter(termenv.Ascii)) +} + +// TestRenderMarkdownUsesPalette renders on a truecolor terminal and checks the +// links use the accent color and code blocks use GitHub's syntax theme. +func TestRenderMarkdownUsesPalette(t *testing.T) { + forceTrueColor(t, true) + out := renderMarkdown("See [docs](https://example.com).\n\n```go\nfunc main() {}\n```", 60) + assert.Contains(t, out, sgr(shared.ColorAccent.Dark, false), "accent link") + assert.Contains(t, out, "\x1b[38;2;255;123;114mfunc", "github-dark keyword color") +} + +func TestRenderMarkdownWithoutColor(t *testing.T) { + profile := lipgloss.ColorProfile() + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + lipgloss.SetColorProfile(termenv.Ascii) + + out := renderMarkdown("# Title\n\n```go\nfunc main() {}\n```", 60) + assert.NotContains(t, out, "38;", "no foreground colors when color is disabled") + assert.NotContains(t, out, "48;", "no background colors when color is disabled") + assert.Contains(t, out, "Title") + assert.Contains(t, out, "func main() {}", "code blocks are not highlighted") +} + +func TestEditorCommand(t *testing.T) { + cmd := editorCommand("code --wait", "/tmp/desc.md") + assert.Equal(t, []string{"code", "--wait", "/tmp/desc.md"}, cmd.Args) + assert.Same(t, os.Stdout, cmd.Stdout, "the editor writes to the terminal, not the program output") + assert.Nil(t, cmd.Stdin, "Bubble Tea supplies its own terminal input") +} diff --git a/internal/tui/submitview/screen.go b/internal/tui/submitview/screen.go index 93eaec43..828faed5 100644 --- a/internal/tui/submitview/screen.go +++ b/internal/tui/submitview/screen.go @@ -689,7 +689,7 @@ type leftRow struct { } // renderLeftPanel renders the stack as a vertical timeline: a circle node per -// branch — filled cyan when it will become a PR, a dotted gray ring when skipped, +// branch — filled accent blue when it will become a PR, a dotted gray ring when skipped, // a state-colored ring for an existing PR — joined by a spine down to the trunk. // Existing PRs show "state · #num" on a second line. The focused branch's full // row (and a gap above/below) is shaded edge to edge. Long names wrap, and the @@ -879,9 +879,9 @@ func leftLine(glyph, body, trailing string, focused bool, fullW int) string { return lead + body + pad(fill, focused) } -// branchCircle renders the timeline node for a branch: a filled cyan circle when -// it will become a PR, a dotted gray ring when skipped, or a state-colored open -// ring for an existing PR. +// branchCircle renders the timeline node for a branch: a filled accent circle +// when it will become a PR, a dotted gray ring when skipped, or a state-colored +// open ring for an existing PR. func (m Model) branchCircle(n SubmitNode, focused bool) string { glyph, color := "○", n.State.Color() switch { diff --git a/internal/tui/submitview/styles.go b/internal/tui/submitview/styles.go index 77e76032..8dd4c754 100644 --- a/internal/tui/submitview/styles.go +++ b/internal/tui/submitview/styles.go @@ -17,16 +17,15 @@ var stateColors = map[BranchState]lipgloss.TerminalColor{ StateClosed: shared.ColorRed, } -// State background tints for pill badges: dark washes on a dark terminal, light -// washes on a light terminal, so the badge reads as a low-opacity tint of its -// foreground color in either mode. -var stateBgColors = map[BranchState]lipgloss.TerminalColor{ - StateNew: lipgloss.AdaptiveColor{Dark: "#10243e", Light: "#cfe7ff"}, - StateOpen: lipgloss.AdaptiveColor{Dark: "#0d2818", Light: "#c8f0d4"}, - StateDraft: lipgloss.AdaptiveColor{Dark: "#272b33", Light: "#e4e9ef"}, - StateQueued: lipgloss.AdaptiveColor{Dark: "#2b2410", Light: "#f4ead9"}, - StateMerged: lipgloss.AdaptiveColor{Dark: "#241a3a", Light: "#ecdcff"}, - StateClosed: lipgloss.AdaptiveColor{Dark: "#2d1417", Light: "#ffdcd7"}, +// State pill badges: each state's color on a faint wash of the same hue, so the +// badge reads as a low-opacity tint of its foreground color in either mode. +var stateBadges = map[BranchState]shared.Badge{ + StateNew: shared.BadgeBlue, + StateOpen: shared.BadgeGreen, + StateDraft: shared.BadgeGray, + StateQueued: shared.BadgeYellow, + StateMerged: shared.BadgePurple, + StateClosed: shared.BadgeRed, } // Label returns the uppercase badge text for a state (e.g. "NEW"). @@ -75,9 +74,10 @@ func (s BranchState) Dot() string { // RenderBadge renders a state as a pill badge: the uppercase label in the state // color on a tinted background with single-column horizontal padding. func RenderBadge(s BranchState) string { + badge := stateBadges[s] return lipgloss.NewStyle(). - Foreground(s.Color()). - Background(stateBgColors[s]). + Foreground(badge.Fg). + Background(badge.Bg). Bold(true). Padding(0, 1). Render(s.Label()) @@ -125,10 +125,10 @@ var ( lockedTitleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) // Footer bottom-right actions: nextBranchStyle is the "NEXT BRANCH" label; - // submitButtonStyle is the prominent inverted "SUBMIT N PRs" button shown on - // the last PR. + // submitButtonStyle is the prominent primary (green) "SUBMIT N PRs" button + // shown on the last PR. nextBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) - submitButtonStyle = lipgloss.NewStyle().Foreground(shared.ColorButtonFg).Background(shared.ColorButtonBg).Bold(true).Padding(0, 1) + submitButtonStyle = lipgloss.NewStyle().Foreground(shared.ColorOnPrimary).Background(shared.ColorPrimaryFill).Bold(true).Padding(0, 1) // prNumberStyle renders a clickable existing-PR number as an underlined link. prNumberStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Underline(true) @@ -136,19 +136,19 @@ var ( spineStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) ruleStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) - // CREATE PR switch in the right-panel header. On: a green pill (matching the - // CREATE AS selected color) with the knob inset on the right. Off: a muted - // track with a darker knob inset on the left. - switchOnStyle = lipgloss.NewStyle().Background(shared.ColorGreen) - switchOffStyle = lipgloss.NewStyle().Background(lipgloss.AdaptiveColor{Dark: "#6e7681", Light: "#afb8c1"}) - switchOnKnob = shared.ColorOnFill // contrasts with the green track - switchOffKnob = lipgloss.AdaptiveColor{Dark: "#1c2128", Light: "#57606a"} + // CREATE PR switch in the right-panel header. On: a primary (green) pill + // matching the CREATE AS selected segment, with a white knob inset on the + // right. Off: a muted track with a darker knob inset on the left. + switchOnStyle = lipgloss.NewStyle().Background(shared.ColorPrimaryFill) + switchOffStyle = lipgloss.NewStyle().Background(shared.ColorToggleOffTrack) + switchOnKnob = shared.ColorOnPrimary + switchOffKnob = shared.ColorToggleOffKnob - // Segmented Ready/Draft control: the selected segment is filled green; the - // other is muted. Brackets/divider are dim chrome. + // Segmented Ready/Draft control: the selected segment is the primary + // (green) fill; the other is muted. Brackets/divider are dim chrome. segOnStyle = lipgloss.NewStyle(). - Foreground(shared.ColorOnFill). - Background(shared.ColorGreen). + Foreground(shared.ColorOnPrimary). + Background(shared.ColorPrimaryFill). Bold(true). Padding(0, 1) segOffStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Padding(0, 1) diff --git a/internal/tui/submitview/styles_test.go b/internal/tui/submitview/styles_test.go index a72be3c0..9a30ffd1 100644 --- a/internal/tui/submitview/styles_test.go +++ b/internal/tui/submitview/styles_test.go @@ -4,7 +4,10 @@ import ( "testing" "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/stretchr/testify/assert" + + "github.com/github/gh-stack/internal/tui/shared" ) func TestStateLabel(t *testing.T) { @@ -40,3 +43,63 @@ func TestRenderBadgeContainsLabel(t *testing.T) { func TestRenderDotContainsGlyph(t *testing.T) { assert.Contains(t, RenderDot(StateNew), StateNew.Dot()) } + +func TestRenderBadgeUsesStateBadgeColors(t *testing.T) { + want := map[BranchState]shared.Badge{ + StateNew: shared.BadgeBlue, + StateOpen: shared.BadgeGreen, + StateDraft: shared.BadgeGray, + StateQueued: shared.BadgeYellow, + StateMerged: shared.BadgePurple, + StateClosed: shared.BadgeRed, + } + for _, dark := range []bool{true, false} { + forceTrueColor(t, dark) + for state, badge := range want { + fg, bg := badge.Fg.Light, badge.Bg.Light + if dark { + fg, bg = badge.Fg.Dark, badge.Bg.Dark + } + out := RenderBadge(state) + assert.Contains(t, out, sgr(fg, false), "%s text (dark=%v)", state.Label(), dark) + assert.Contains(t, out, sgr(bg, true), "%s wash (dark=%v)", state.Label(), dark) + } + } +} + +// TestPrimaryActionsUseBrandFill verifies the submit button, the selected +// Ready/Draft segment, and the enabled CREATE PR switch share the Primer Brand +// primary button colors: white on green. +func TestPrimaryActionsUseBrandFill(t *testing.T) { + forceTrueColor(t, true) + fill := sgr(string(shared.ColorPrimaryFill), true) + ink := sgr(string(shared.ColorOnPrimary), false) + for name, out := range map[string]string{ + "submit button": submitButtonStyle.Render("SUBMIT 2 PRs"), + "selected segment": segOnStyle.Render("Ready"), + "enabled switch": renderSwitch(true), + } { + assert.Contains(t, out, fill, name) + assert.Contains(t, out, ink, name) + } + assert.NotContains(t, renderSwitch(false), fill, "a disabled switch is not a primary action") +} + +// forceTrueColor renders with the default renderer at truecolor on a dark or +// light background for the rest of the test. +func forceTrueColor(t *testing.T, dark bool) { + t.Helper() + profile, bg := lipgloss.ColorProfile(), lipgloss.HasDarkBackground() + t.Cleanup(func() { + lipgloss.SetColorProfile(profile) + lipgloss.SetHasDarkBackground(bg) + }) + lipgloss.SetColorProfile(termenv.TrueColor) + lipgloss.SetHasDarkBackground(dark) +} + +// sgr returns the truecolor SGR parameters lipgloss emits to select hex as a +// foreground or background color. +func sgr(hex string, background bool) string { + return termenv.TrueColor.Color(hex).Sequence(background) +}