Add supports for terminal hyperlinks in output (v2)#12682
Add supports for terminal hyperlinks in output (v2)#12682
Conversation
In supporting terminals (e.g. iTerm), the identifier field of table-based output like `issue/pr list` and `pr checks` is now a link that can be followed in a browser by Cmd-clicking it. This commit is a modernized version of cli#3309/ ca3f730. The following commands have updated: - `gh pr checks`: `NAME` is link to the job; `URL` is removed - `gh issue/pr list`: `ID` is link to the issue/pr Co-authored-by: Mislav Marohnić <[email protected]>
The following commands have updated: - `gh gist list`: `DESCRIPTION` is link to the gist - `gh repo list`: `NAME` is link to the repo - `gh search commits`: `REPO` and `SHA` are links to the repo and commit - `gh search issues/prs`: `ID` is link to the issue/pr - `gh search repos`: `REPO` is link to the repo
There was a problem hiding this comment.
Pull request overview
Adds opt-in OSC 8 terminal hyperlink support across several table-based command outputs, gated by the GH_HYPERLINK environment variable.
Changes:
- Introduces
linkEnabledonIOStreams/ColorSchemeand adds hyperlink helper methods. - Updates multiple table renderers (PR checks, PR/issue list, repo list, search results, gist list) to render key columns as hyperlinks and, in some cases, remove redundant URL columns.
- Extends gist GraphQL query mapping to include the gist URL for hyperlinking.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/iostreams/iostreams.go | Adds linkEnabled to IOStreams, getter, and wires env var GH_HYPERLINK into system IOStreams. |
| pkg/iostreams/color.go | Adds OSC 8 hyperlink rendering helpers to ColorScheme. |
| pkg/cmd/search/shared/shared.go | Makes issue/PR IDs clickable in search results when enabled. |
| pkg/cmd/search/repos/repos.go | Makes repo names clickable in repo search results. |
| pkg/cmd/search/commits/commits.go | Makes repo and SHA clickable in commit search results. |
| pkg/cmd/repo/list/list.go | Makes repo names clickable in gh repo list and conditionally fetches url. |
| pkg/cmd/pr/list/list.go | Makes PR IDs clickable in gh pr list. |
| pkg/cmd/pr/checks/output.go | Makes check NAME clickable and drops URL column in TTY output when enabled. |
| pkg/cmd/issue/shared/display.go | Makes issue IDs clickable in issue list output. |
| pkg/cmd/gist/shared/shared.go | Fetches gist URL via GraphQL for hyperlinking in list output. |
| pkg/cmd/gist/list/list.go | Makes gist description clickable in gh gist list. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Call c.Hyperlink first, then colorize. | ||
| // Otherwise space-trimming logic in c.Hyperlink wouldn't work. | ||
| return colorize(c.Hyperlink(text, url)) |
There was a problem hiding this comment.
WithHyperlink currently calls Hyperlink before invoking the provided colorize function. This is unsafe for colorizers that transform the input based on string indices/content (e.g., highlightMatch in gist list), because the injected OSC 8 bytes will shift indices and can corrupt both highlighting and the hyperlink escape sequence. Consider restructuring so you first split/truncate trailing padding from the original text, then apply colorize to the non-padding portion, and finally wrap that result in the OSC 8 sequence while appending the original trailing spaces outside the link.
| // Call c.Hyperlink first, then colorize. | |
| // Otherwise space-trimming logic in c.Hyperlink wouldn't work. | |
| return colorize(c.Hyperlink(text, url)) | |
| // Make trailing spaces not to be part of the link as it looks ugly, ... | |
| link_text := strings.TrimRight(text, " ") | |
| trailing_spaces := text[len(link_text):] | |
| if link_text == "" { | |
| // ... but still allow spaces-only text to be clickable. | |
| link_text = text | |
| trailing_spaces = "" | |
| } | |
| // Apply colorization only to the part that will be inside the hyperlink, | |
| // so index-based colorizers operate on the original, unmodified text. | |
| colored_link_text := colorize(link_text) | |
| // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda | |
| return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\%s", url, colored_link_text, trailing_spaces) |
| link_text := strings.TrimRight(text, " ") | ||
| trailing_spaces := text[len(link_text):] | ||
| if link_text == "" { | ||
| // ... but still allow spaces-only text to be clickable. | ||
| link_text = text | ||
| trailing_spaces = "" | ||
| } | ||
|
|
||
| // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda | ||
| return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\%s", url, link_text, trailing_spaces) |
There was a problem hiding this comment.
Go style in this package uses camelCase for local variables; link_text and trailing_spaces stand out and make the new logic harder to read/grep consistently. Please rename to linkText / trailingSpaces (and similar) for idiomatic Go.
| link_text := strings.TrimRight(text, " ") | |
| trailing_spaces := text[len(link_text):] | |
| if link_text == "" { | |
| // ... but still allow spaces-only text to be clickable. | |
| link_text = text | |
| trailing_spaces = "" | |
| } | |
| // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda | |
| return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\%s", url, link_text, trailing_spaces) | |
| linkText := strings.TrimRight(text, " ") | |
| trailingSpaces := text[len(linkText):] | |
| if linkText == "" { | |
| // ... but still allow spaces-only text to be clickable. | |
| linkText = text | |
| trailingSpaces = "" | |
| } | |
| // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda | |
| return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\%s", url, linkText, trailingSpaces) |
| func (c *ColorScheme) Hyperlink(text, url string) string { | ||
| if !c.linkEnabled { | ||
| return text | ||
| } | ||
|
|
||
| // Make trailing spaces not to be part of the link as it looks ugly, ... | ||
| link_text := strings.TrimRight(text, " ") | ||
| trailing_spaces := text[len(link_text):] | ||
| if link_text == "" { | ||
| // ... but still allow spaces-only text to be clickable. | ||
| link_text = text | ||
| trailing_spaces = "" | ||
| } | ||
|
|
||
| // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda | ||
| return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\%s", url, link_text, trailing_spaces) | ||
| } | ||
|
|
||
| func (c *ColorScheme) WithHyperlink(url string, colorize func(string) string) func(string) string { | ||
| if colorize == nil { | ||
| colorize = func(s string) string { return s } | ||
| } | ||
| if !c.linkEnabled { | ||
| return colorize | ||
| } | ||
| return func(text string) string { | ||
| // Call c.Hyperlink first, then colorize. | ||
| // Otherwise space-trimming logic in c.Hyperlink wouldn't work. | ||
| return colorize(c.Hyperlink(text, url)) | ||
| } | ||
| } |
There was a problem hiding this comment.
New hyperlink rendering logic in Hyperlink/WithHyperlink isn’t covered by unit tests. Since pkg/iostreams/color_test.go already exists, please add tests for: links disabled (returns input), links enabled (OSC 8 wrapper), and the trailing-space handling (spaces excluded from link but preserved in output).
| tp.AddField(gist.ID) | ||
| tp.AddField( | ||
| text.RemoveExcessiveWhitespace(description), | ||
| tableprinter.WithColor(highlightDescription), | ||
| tableprinter.WithColor(cs.WithHyperlink(gist.HTMLURL, highlightDescription)), | ||
| ) |
There was a problem hiding this comment.
highlightDescription uses highlightMatch (regex + index-based slicing) to produce colored output. Passing it to cs.WithHyperlink means the hyperlink escape sequence is injected before highlighting runs, which can break match highlighting and may corrupt the OSC 8 sequence. After adjusting WithHyperlink, ensure this call site highlights the plain description first (or otherwise avoids running index-based transforms on already-escaped text).
| func (s *IOStreams) IsLinkEnabled() bool { | ||
| return s.linkEnabled | ||
| } | ||
|
|
There was a problem hiding this comment.
IsLinkEnabled is currently read-only, and iostreams.Test() doesn’t set linkEnabled from GH_HYPERLINK. That makes it difficult for command-level tests (outside the iostreams package) to enable and assert hyperlink output. Consider adding a SetLinkEnabled(bool) helper (similar to SetColorEnabled) or having Test() honor the env var so the new behavior can be exercised in existing command tests.
| func (s *IOStreams) SetLinkEnabled(enabled bool) { | |
| s.linkEnabled = enabled | |
| } |
| func printTable(io *iostreams.IOStreams, checks []check) error { | ||
| var headers []string | ||
| if io.IsStdoutTTY() { | ||
| headers = []string{"", "NAME", "DESCRIPTION", "ELAPSED", "URL"} | ||
| headers = []string{"", "NAME", "DESCRIPTION", "ELAPSED"} | ||
| if !io.IsLinkEnabled() { | ||
| headers = append(headers, "URL") | ||
| } | ||
| } else { | ||
| headers = []string{"NAME", "STATUS", "ELAPSED", "URL", "DESCRIPTION"} | ||
| } |
There was a problem hiding this comment.
When IsLinkEnabled() is true, the TTY table drops the URL column and turns NAME into a hyperlink. There are extensive tests for the TTY output in this command, but none cover the link-enabled header/row shape; please add a test case that enables hyperlinks and asserts the URL column is omitted and NAME contains the OSC 8 sequence.
This is a resurrection of #3309.
Why revisit this PR again
The previous PR got closed due to lack of support from terminals. Situation improved since then.
Chronology: In 2021 and 2022 the team decided not to merge it due to lack of support in terminal emulators. In 2024, @williammartin suggested that it could be revisited.
Referring to points from the original PR:
lessseems to be actively maintained: https://github.com/search?q=repo%3Agwsw%2Fless+OSC+8&type=commits. I see no issues on my setup.Details
In this PR, hyperlinks are enabled when
GH_HYPERLINKis non-empty. When enabled, the following arbitrarily chosen1 tables are updated:gh pr checks:NAMEis link to the job;URLis removedgh issue/pr list:IDis link to the issue/prgh gist list:DESCRIPTIONis link to the gistgh repo list:NAMEis link to the repogh search commits:REPOandSHAare links to the repo and commitgh search issues/prs:IDis link to the issue/prgh search repos:REPOis link to the repoFootnotes
I think there are still a lot of remaining tables that could be augmented by hyperlinks. ↩