print full urls when terminal doesn't support osc 8 hyperlinks (#479)

This commit is contained in:
Sameen Karim
2026-08-27 13:34:37 -04:00
committed by GitHub
parent 3765b9bb2f
commit 2bd699a544
5 changed files with 219 additions and 9 deletions
+5 -1
View File
@@ -489,7 +489,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:**
@@ -500,6 +500,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`.
+21
View File
@@ -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) {
+6 -1
View File
@@ -110,7 +110,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`).
@@ -123,6 +123,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.
@@ -659,6 +663,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
+52 -7
View File
@@ -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
+135
View File
@@ -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))
})
}
}