Skip to content

Commit 19fb274

Browse files
committed
print full urls when terminal doesn't support osc 8 hyperlinks
1 parent 4f9188e commit 19fb274

5 files changed

Lines changed: 219 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ Shows all branches in the stack, their ordering, PR links, and the most recent c
487487

488488
| Flag | Description |
489489
|------|-------------|
490-
| `-s, --short` | Compact output (branch names only) |
490+
| `-s, --short` | Compact one-line-per-branch output |
491491
| `--json` | Output stack data as JSON |
492492

493493
**Examples:**
@@ -498,6 +498,10 @@ gh stack view --short
498498
gh stack view --json
499499
```
500500

501+
`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal
502+
supports them. Otherwise, the full URL is shown for copy/paste. Set
503+
`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection.
504+
501505
### `gh stack unstack`
502506

503507
Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`.

‎cmd/view_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,27 @@ func TestViewShort_ActiveStack(t *testing.T) {
245245
assert.Contains(t, output, "main")
246246
}
247247

248+
func TestShortPRSuffix_PlainURLFallback(t *testing.T) {
249+
t.Setenv("GH_STACK_HYPERLINKS", "0")
250+
251+
cfg, outR, errR := config.NewTestConfig()
252+
defer cfg.Out.Close()
253+
defer cfg.Err.Close()
254+
defer outR.Close()
255+
defer errR.Close()
256+
257+
b := stack.BranchRef{
258+
PullRequest: &stack.PullRequestRef{
259+
Number: 42,
260+
URL: "https://github.com/o/r/pull/42",
261+
},
262+
}
263+
264+
suffix := shortPRSuffix(cfg, b, "", "", "")
265+
assert.Equal(t, " #42 (https://github.com/o/r/pull/42)", suffix)
266+
assert.NotContains(t, suffix, "\x1b]8")
267+
}
268+
248269
// TestViewShort_FullyMergedStack verifies that --short output shows merged
249270
// branches correctly when all branches in the stack are merged.
250271
func TestViewShort_FullyMergedStack(t *testing.T) {

‎docs/src/content/docs/reference/cli.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ gh stack view [flags]
108108

109109
| Flag | Description |
110110
|------|-------------|
111-
| `-s, --short` | Compact output (branch names only) |
111+
| `-s, --short` | Compact one-line-per-branch output |
112112
| `--json` | Output stack data as JSON |
113113

114114
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
121121
gh stack view --json
122122
```
123123

124+
`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal
125+
supports them. Otherwise, the full URL is shown for copy/paste. Set
126+
`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection.
127+
124128
### `gh stack checkout`
125129

126130
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"
657661
| Variable | Values | Description |
658662
|----------|--------|-------------|
659663
| `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). |
664+
| `GH_STACK_HYPERLINKS` | `0`, `1` | Disables or enables OSC 8 hyperlinks when terminal detection is incorrect. Unsupported terminals show the full URL by default. |
660665

661666
```sh
662667
# Force the light palette for one command

‎internal/config/config.go‎

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package config
33
import (
44
"fmt"
55
"os"
6+
"strconv"
7+
"strings"
68

79
"github.com/cli/go-gh/v2/pkg/repository"
810
"github.com/cli/go-gh/v2/pkg/term"
@@ -87,6 +89,47 @@ func New() *Config {
8789
return cfg
8890
}
8991

92+
func supportsHyperlinks(isTTY bool) bool {
93+
switch strings.ToLower(os.Getenv("GH_STACK_HYPERLINKS")) {
94+
case "1", "true", "yes", "on":
95+
return true
96+
case "0", "false", "no", "off":
97+
return false
98+
}
99+
if !isTTY {
100+
return false
101+
}
102+
103+
termName := strings.ToLower(os.Getenv("TERM"))
104+
if termName == "dumb" ||
105+
os.Getenv("TMUX") != "" ||
106+
os.Getenv("STY") != "" ||
107+
strings.HasPrefix(termName, "screen") ||
108+
strings.HasPrefix(termName, "tmux") {
109+
return false
110+
}
111+
112+
switch strings.ToLower(os.Getenv("TERM_PROGRAM")) {
113+
case "alacritty", "ghostty", "hyper", "iterm.app", "mintty", "rio", "tabby", "vscode", "warpterminal", "wezterm":
114+
return true
115+
}
116+
117+
if version, err := strconv.Atoi(os.Getenv("VTE_VERSION")); err == nil && version >= 5000 {
118+
return true
119+
}
120+
if os.Getenv("WT_SESSION") != "" ||
121+
os.Getenv("KITTY_WINDOW_ID") != "" {
122+
return true
123+
}
124+
125+
for _, supported := range []string{"alacritty", "contour", "foot", "ghostty", "kitty", "wezterm"} {
126+
if strings.Contains(termName, supported) {
127+
return true
128+
}
129+
}
130+
return false
131+
}
132+
90133
func (c *Config) Successf(format string, args ...any) {
91134
fmt.Fprintf(c.Err, "%s %s\n", c.ColorSuccess("\u2713"), fmt.Sprintf(format, args...))
92135
}
@@ -111,16 +154,18 @@ func (c *Config) Outf(format string, args ...any) {
111154
fmt.Fprintf(c.Out, format, args...)
112155
}
113156

114-
// PRLink formats a PR number as a clickable, underlined terminal hyperlink.
115-
// Falls back to plain "#N" when color is disabled.
157+
// PRLink formats a PR number as a clickable terminal hyperlink when supported,
158+
// or includes the full URL as a copyable fallback.
116159
func (c *Config) PRLink(number int, url string) string {
160+
hyperlinksEnabled := supportsHyperlinks(c.Terminal.IsTerminalOutput())
117161
label := fmt.Sprintf("#%d", number)
118-
if c.Terminal.IsColorEnabled() {
119-
if url != "" {
120-
// OSC 8 hyperlink
121-
label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label)
162+
if url != "" {
163+
if !hyperlinksEnabled {
164+
return fmt.Sprintf("%s (%s)", label, url)
122165
}
123-
// Underline
166+
label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label)
167+
}
168+
if c.Terminal.IsColorEnabled() {
124169
label = fmt.Sprintf("\033[4m%s\033[24m", label)
125170
}
126171
return label

‎internal/config/hyperlink_test.go‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestSupportsHyperlinks(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
isTTY bool
13+
env map[string]string
14+
want bool
15+
}{
16+
{
17+
name: "force enabled without TTY",
18+
env: map[string]string{"GH_STACK_HYPERLINKS": "1"},
19+
want: true,
20+
},
21+
{
22+
name: "force disabled in supported terminal",
23+
isTTY: true,
24+
env: map[string]string{"GH_STACK_HYPERLINKS": "0", "TERM_PROGRAM": "iTerm.app"},
25+
},
26+
{
27+
name: "supported terminal without TTY",
28+
env: map[string]string{"TERM_PROGRAM": "iTerm.app"},
29+
},
30+
{
31+
name: "tmux overrides outer terminal",
32+
isTTY: true,
33+
env: map[string]string{"TMUX": "/tmp/tmux-501/default,1,0", "TERM_PROGRAM": "iTerm.app"},
34+
},
35+
{
36+
name: "Apple Terminal unsupported",
37+
isTTY: true,
38+
env: map[string]string{"TERM_PROGRAM": "Apple_Terminal"},
39+
},
40+
{
41+
name: "unknown terminal unsupported",
42+
isTTY: true,
43+
env: map[string]string{"TERM": "xterm-256color"},
44+
},
45+
{
46+
name: "Konsole defaults unsupported",
47+
isTTY: true,
48+
env: map[string]string{"KONSOLE_VERSION": "210401"},
49+
},
50+
{
51+
name: "iTerm supported",
52+
isTTY: true,
53+
env: map[string]string{"TERM_PROGRAM": "iTerm.app"},
54+
want: true,
55+
},
56+
{
57+
name: "VTE 0.50 supported",
58+
isTTY: true,
59+
env: map[string]string{"VTE_VERSION": "5000"},
60+
want: true,
61+
},
62+
{
63+
name: "Windows Terminal supported",
64+
isTTY: true,
65+
env: map[string]string{"WT_SESSION": "session-id"},
66+
want: true,
67+
},
68+
{
69+
name: "kitty supported",
70+
isTTY: true,
71+
env: map[string]string{"TERM": "xterm-kitty"},
72+
want: true,
73+
},
74+
}
75+
76+
envVars := []string{
77+
"GH_STACK_HYPERLINKS",
78+
"KITTY_WINDOW_ID",
79+
"KONSOLE_VERSION",
80+
"STY",
81+
"TERM",
82+
"TERM_PROGRAM",
83+
"TMUX",
84+
"VTE_VERSION",
85+
"WT_SESSION",
86+
}
87+
for _, tt := range tests {
88+
t.Run(tt.name, func(t *testing.T) {
89+
for _, name := range envVars {
90+
t.Setenv(name, "")
91+
}
92+
for name, value := range tt.env {
93+
t.Setenv(name, value)
94+
}
95+
96+
assert.Equal(t, tt.want, supportsHyperlinks(tt.isTTY))
97+
})
98+
}
99+
}
100+
101+
func TestPRLinkFormatting(t *testing.T) {
102+
const url = "https://github.com/o/r/pull/42"
103+
tests := []struct {
104+
name string
105+
forceHyperlink string
106+
url string
107+
want string
108+
}{
109+
{
110+
name: "OSC 8 hyperlink",
111+
forceHyperlink: "1",
112+
url: url,
113+
want: "\x1b]8;;https://github.com/o/r/pull/42\x1b\\#42\x1b]8;;\x1b\\",
114+
},
115+
{
116+
name: "plain URL fallback",
117+
forceHyperlink: "0",
118+
url: url,
119+
want: "#42 (https://github.com/o/r/pull/42)",
120+
},
121+
{
122+
name: "missing URL",
123+
forceHyperlink: "0",
124+
want: "#42",
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
t.Setenv("GH_STACK_HYPERLINKS", tt.forceHyperlink)
131+
cfg := &Config{}
132+
assert.Equal(t, tt.want, cfg.PRLink(42, tt.url))
133+
})
134+
}
135+
}

0 commit comments

Comments
 (0)