diff --git a/README.md b/README.md index 2729d0a3..3feeab74 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ Shows all branches in the stack, their ordering, PR links, and the most recent c | Flag | Description | |------|-------------| -| `-s, --short` | Compact output (branch names only) | +| `-s, --short` | Compact one-line-per-branch output | | `--json` | Output stack data as JSON | **Examples:** @@ -498,6 +498,10 @@ gh stack view --short gh stack view --json ``` +`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal +supports them. Otherwise, the full URL is shown for copy/paste. Set +`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection. + ### `gh stack unstack` Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`. diff --git a/cmd/view_test.go b/cmd/view_test.go index cb228c48..a7a6e0b6 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -245,6 +245,27 @@ func TestViewShort_ActiveStack(t *testing.T) { assert.Contains(t, output, "main") } +func TestShortPRSuffix_PlainURLFallback(t *testing.T) { + t.Setenv("GH_STACK_HYPERLINKS", "0") + + cfg, outR, errR := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + defer outR.Close() + defer errR.Close() + + b := stack.BranchRef{ + PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/o/r/pull/42", + }, + } + + suffix := shortPRSuffix(cfg, b, "", "", "") + assert.Equal(t, " #42 (https://github.com/o/r/pull/42)", suffix) + assert.NotContains(t, suffix, "\x1b]8") +} + // TestViewShort_FullyMergedStack verifies that --short output shows merged // branches correctly when all branches in the stack are merged. func TestViewShort_FullyMergedStack(t *testing.T) { diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 33084e30..2ed97b92 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -108,7 +108,7 @@ gh stack view [flags] | Flag | Description | |------|-------------| -| `-s, --short` | Compact output (branch names only) | +| `-s, --short` | Compact one-line-per-branch output | | `--json` | Output stack data as JSON | Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`). @@ -121,6 +121,10 @@ gh stack view --short gh stack view --json ``` +`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal +supports them. Otherwise, the full URL is shown for copy/paste. Set +`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection. + ### `gh stack checkout` Check out a stack by its stack number, a pull request number, a PR URL, or a branch name. @@ -657,6 +661,7 @@ 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_HYPERLINKS` | `0`, `1` | Disables or enables OSC 8 hyperlinks when terminal detection is incorrect. Unsupported terminals show the full URL by default. | ```sh # Force the light palette for one command diff --git a/internal/config/config.go b/internal/config/config.go index bb411243..594b57cb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,8 @@ package config import ( "fmt" "os" + "strconv" + "strings" "github.com/cli/go-gh/v2/pkg/repository" "github.com/cli/go-gh/v2/pkg/term" @@ -87,6 +89,47 @@ func New() *Config { return cfg } +func supportsHyperlinks(isTTY bool) bool { + switch strings.ToLower(os.Getenv("GH_STACK_HYPERLINKS")) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + if !isTTY { + return false + } + + termName := strings.ToLower(os.Getenv("TERM")) + if termName == "dumb" || + os.Getenv("TMUX") != "" || + os.Getenv("STY") != "" || + strings.HasPrefix(termName, "screen") || + strings.HasPrefix(termName, "tmux") { + return false + } + + switch strings.ToLower(os.Getenv("TERM_PROGRAM")) { + case "alacritty", "ghostty", "hyper", "iterm.app", "mintty", "rio", "tabby", "vscode", "warpterminal", "wezterm": + return true + } + + if version, err := strconv.Atoi(os.Getenv("VTE_VERSION")); err == nil && version >= 5000 { + return true + } + if os.Getenv("WT_SESSION") != "" || + os.Getenv("KITTY_WINDOW_ID") != "" { + return true + } + + for _, supported := range []string{"alacritty", "contour", "foot", "ghostty", "kitty", "wezterm"} { + if strings.Contains(termName, supported) { + return true + } + } + return false +} + func (c *Config) Successf(format string, args ...any) { fmt.Fprintf(c.Err, "%s %s\n", c.ColorSuccess("\u2713"), fmt.Sprintf(format, args...)) } @@ -111,16 +154,18 @@ func (c *Config) Outf(format string, args ...any) { fmt.Fprintf(c.Out, format, args...) } -// PRLink formats a PR number as a clickable, underlined terminal hyperlink. -// Falls back to plain "#N" when color is disabled. +// PRLink formats a PR number as a clickable terminal hyperlink when supported, +// or includes the full URL as a copyable fallback. func (c *Config) PRLink(number int, url string) string { + hyperlinksEnabled := supportsHyperlinks(c.Terminal.IsTerminalOutput()) label := fmt.Sprintf("#%d", number) - if c.Terminal.IsColorEnabled() { - if url != "" { - // OSC 8 hyperlink - label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label) + if url != "" { + if !hyperlinksEnabled { + return fmt.Sprintf("%s (%s)", label, url) } - // Underline + label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label) + } + if c.Terminal.IsColorEnabled() { label = fmt.Sprintf("\033[4m%s\033[24m", label) } return label diff --git a/internal/config/hyperlink_test.go b/internal/config/hyperlink_test.go new file mode 100644 index 00000000..a505a7a8 --- /dev/null +++ b/internal/config/hyperlink_test.go @@ -0,0 +1,135 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSupportsHyperlinks(t *testing.T) { + tests := []struct { + name string + isTTY bool + env map[string]string + want bool + }{ + { + name: "force enabled without TTY", + env: map[string]string{"GH_STACK_HYPERLINKS": "1"}, + want: true, + }, + { + name: "force disabled in supported terminal", + isTTY: true, + env: map[string]string{"GH_STACK_HYPERLINKS": "0", "TERM_PROGRAM": "iTerm.app"}, + }, + { + name: "supported terminal without TTY", + env: map[string]string{"TERM_PROGRAM": "iTerm.app"}, + }, + { + name: "tmux overrides outer terminal", + isTTY: true, + env: map[string]string{"TMUX": "/tmp/tmux-501/default,1,0", "TERM_PROGRAM": "iTerm.app"}, + }, + { + name: "Apple Terminal unsupported", + isTTY: true, + env: map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, + }, + { + name: "unknown terminal unsupported", + isTTY: true, + env: map[string]string{"TERM": "xterm-256color"}, + }, + { + name: "Konsole defaults unsupported", + isTTY: true, + env: map[string]string{"KONSOLE_VERSION": "210401"}, + }, + { + name: "iTerm supported", + isTTY: true, + env: map[string]string{"TERM_PROGRAM": "iTerm.app"}, + want: true, + }, + { + name: "VTE 0.50 supported", + isTTY: true, + env: map[string]string{"VTE_VERSION": "5000"}, + want: true, + }, + { + name: "Windows Terminal supported", + isTTY: true, + env: map[string]string{"WT_SESSION": "session-id"}, + want: true, + }, + { + name: "kitty supported", + isTTY: true, + env: map[string]string{"TERM": "xterm-kitty"}, + want: true, + }, + } + + envVars := []string{ + "GH_STACK_HYPERLINKS", + "KITTY_WINDOW_ID", + "KONSOLE_VERSION", + "STY", + "TERM", + "TERM_PROGRAM", + "TMUX", + "VTE_VERSION", + "WT_SESSION", + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, name := range envVars { + t.Setenv(name, "") + } + for name, value := range tt.env { + t.Setenv(name, value) + } + + assert.Equal(t, tt.want, supportsHyperlinks(tt.isTTY)) + }) + } +} + +func TestPRLinkFormatting(t *testing.T) { + const url = "https://github.com/o/r/pull/42" + tests := []struct { + name string + forceHyperlink string + url string + want string + }{ + { + name: "OSC 8 hyperlink", + forceHyperlink: "1", + url: url, + want: "\x1b]8;;https://github.com/o/r/pull/42\x1b\\#42\x1b]8;;\x1b\\", + }, + { + name: "plain URL fallback", + forceHyperlink: "0", + url: url, + want: "#42 (https://github.com/o/r/pull/42)", + }, + { + name: "missing URL", + forceHyperlink: "0", + want: "#42", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GH_STACK_HYPERLINKS", tt.forceHyperlink) + cfg := &Config{} + assert.Equal(t, tt.want, cfg.PRLink(42, tt.url)) + }) + } +}