Skip to content

Commit 32a9534

Browse files
authored
feat(style): hyperlink PR numbers in submit output (#107)
* feat(style): hyperlink PR numbers in submit output (#105) submit prints "Updating PR #N for branch (base: parent)..." with no URL. Per the issue, the PR number should be the linked text when the terminal supports OSC 8 hyperlinks; otherwise fall back to "text (URL)" so the URL remains visible. Added Hyperlink(text, url) to internal/style/style.go: - Color/TTY enabled: emits OSC 8 escape sequence (\x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\), which renders as a clickable link in iTerm2, kitty, recent macOS Terminal, GNOME Terminal, etc. - Color/TTY disabled (NO_COLOR, piped output, dumb terminal): falls back to "text (url)". - Empty URL: returns text alone, no parens. Updated cmd/submit.go:465 to wrap "PR #N" with Hyperlink() and ghClient.PRURL(d.prNum). The (base: parent) suffix and trailing "... ok" / "failed" are unchanged. Added internal/style/style_test.go covering the three branches (enabled, disabled, empty URL). Closes #105 * fix(style): gate OSC 8 hyperlinks on TTY and assert full sequence Address review feedback from copilot-pull-request-reviewer[bot]: - Hyperlink now requires both s.enabled and s.isTTY before emitting OSC 8, so CLICOLOR_FORCE / GH_FORCE_TTY no longer leaks escape sequences into piped output. NewWithColor(enabled) sets isTTY to match enabled so existing test behavior is preserved. - TestHyperlinkColorsEnabled now asserts the full OSC 8 sequence including the closing terminator, catching malformed or truncated output that the prior substring checks would have missed. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1 parent 2b49c2b commit 32a9534

3 files changed

Lines changed: 58 additions & 3 deletions

File tree

cmd/submit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ func executePRDecisions(g *git.Git, cfg *config.Config, root *tree.Node, decisio
462462
if opts.DryRun {
463463
fmt.Printf("%s Would update PR #%d base to %s\n", s.Muted("dry-run:"), d.prNum, s.Branch(parent))
464464
} else {
465-
fmt.Printf("Updating PR #%d for %s (base: %s)... ", d.prNum, s.Branch(b.Name), s.Branch(parent))
465+
fmt.Printf("Updating %s for %s (base: %s)... ", s.Hyperlink(fmt.Sprintf("PR #%d", d.prNum), ghClient.PRURL(d.prNum)), s.Branch(b.Name), s.Branch(parent))
466466
if err := ghClient.UpdatePRBase(d.prNum, parent); err != nil {
467467
fmt.Println(s.Error("failed"))
468468
fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), d.prNum, err)

internal/style/style.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
// All methods return plain text when colors are disabled.
1616
type Style struct {
1717
enabled bool
18+
isTTY bool
1819
}
1920

2021
var (
@@ -54,13 +55,19 @@ func isColorEnabled() bool {
5455
// New creates a new Style instance.
5556
// Colors are automatically enabled/disabled based on terminal capabilities.
5657
func New() *Style {
57-
return &Style{enabled: isColorEnabled()}
58+
return &Style{
59+
enabled: isColorEnabled(),
60+
isTTY: getTermState().IsTerminalOutput(),
61+
}
5862
}
5963

6064
// NewWithColor creates a Style with explicit color setting.
6165
// Useful for testing or forcing color on/off.
6266
func NewWithColor(enabled bool) *Style {
63-
return &Style{enabled: enabled}
67+
return &Style{
68+
enabled: enabled,
69+
isTTY: enabled,
70+
}
6471
}
6572

6673
// Enabled returns whether colors are enabled.
@@ -191,3 +198,20 @@ func (s *Style) WarningMessage(msg string) string {
191198
func (s *Style) FailureMessage(msg string) string {
192199
return fmt.Sprintf("%s %s", s.FailureIcon(), s.Error(msg))
193200
}
201+
202+
// Hyperlink renders text as a terminal hyperlink to url using the OSC 8
203+
// escape sequence when colors/TTY are enabled. When the terminal can't
204+
// support it (NO_COLOR, piped output, dumb terminal), it falls back to
205+
// "text (url)" so the URL stays visible to the user.
206+
//
207+
// Reference: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
208+
func (s *Style) Hyperlink(text, url string) string {
209+
if !s.enabled || !s.isTTY || url == "" {
210+
if url == "" {
211+
return text
212+
}
213+
return fmt.Sprintf("%s (%s)", text, url)
214+
}
215+
// OSC 8 hyperlink: ESC]8;;URLESC\TEXTESC]8;;ESC\
216+
return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text)
217+
}

internal/style/style_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package style
2+
3+
import "testing"
4+
5+
func TestHyperlinkColorsDisabled(t *testing.T) {
6+
s := NewWithColor(false)
7+
got := s.Hyperlink("PR #42", "https://github.com/owner/repo/pull/42")
8+
want := "PR #42 (https://github.com/owner/repo/pull/42)"
9+
if got != want {
10+
t.Errorf("Hyperlink fallback: got %q, want %q", got, want)
11+
}
12+
}
13+
14+
func TestHyperlinkColorsEnabled(t *testing.T) {
15+
s := NewWithColor(true)
16+
got := s.Hyperlink("PR #42", "https://github.com/owner/repo/pull/42")
17+
want := "\x1b]8;;https://github.com/owner/repo/pull/42\x1b\\PR #42\x1b]8;;\x1b\\"
18+
if got != want {
19+
t.Errorf("Hyperlink OSC 8: got %q, want %q", got, want)
20+
}
21+
}
22+
23+
func TestHyperlinkEmptyURL(t *testing.T) {
24+
for _, enabled := range []bool{false, true} {
25+
s := NewWithColor(enabled)
26+
got := s.Hyperlink("PR #42", "")
27+
if got != "PR #42" {
28+
t.Errorf("Hyperlink with empty URL (enabled=%v): got %q, want %q", enabled, got, "PR #42")
29+
}
30+
}
31+
}

0 commit comments

Comments
 (0)