diff --git a/cmd/submit.go b/cmd/submit.go index 4063c80..a183af1 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -462,7 +462,7 @@ func executePRDecisions(g *git.Git, cfg *config.Config, root *tree.Node, decisio if opts.DryRun { fmt.Printf("%s Would update PR #%d base to %s\n", s.Muted("dry-run:"), d.prNum, s.Branch(parent)) } else { - fmt.Printf("Updating PR #%d for %s (base: %s)... ", d.prNum, s.Branch(b.Name), s.Branch(parent)) + 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)) if err := ghClient.UpdatePRBase(d.prNum, parent); err != nil { fmt.Println(s.Error("failed")) fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), d.prNum, err) diff --git a/internal/style/style.go b/internal/style/style.go index e4c5083..6fc3d44 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -15,6 +15,7 @@ import ( // All methods return plain text when colors are disabled. type Style struct { enabled bool + isTTY bool } var ( @@ -54,13 +55,19 @@ func isColorEnabled() bool { // New creates a new Style instance. // Colors are automatically enabled/disabled based on terminal capabilities. func New() *Style { - return &Style{enabled: isColorEnabled()} + return &Style{ + enabled: isColorEnabled(), + isTTY: getTermState().IsTerminalOutput(), + } } // NewWithColor creates a Style with explicit color setting. // Useful for testing or forcing color on/off. func NewWithColor(enabled bool) *Style { - return &Style{enabled: enabled} + return &Style{ + enabled: enabled, + isTTY: enabled, + } } // Enabled returns whether colors are enabled. @@ -191,3 +198,20 @@ func (s *Style) WarningMessage(msg string) string { func (s *Style) FailureMessage(msg string) string { return fmt.Sprintf("%s %s", s.FailureIcon(), s.Error(msg)) } + +// Hyperlink renders text as a terminal hyperlink to url using the OSC 8 +// escape sequence when colors/TTY are enabled. When the terminal can't +// support it (NO_COLOR, piped output, dumb terminal), it falls back to +// "text (url)" so the URL stays visible to the user. +// +// Reference: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda +func (s *Style) Hyperlink(text, url string) string { + if !s.enabled || !s.isTTY || url == "" { + if url == "" { + return text + } + return fmt.Sprintf("%s (%s)", text, url) + } + // OSC 8 hyperlink: ESC]8;;URLESC\TEXTESC]8;;ESC\ + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text) +} diff --git a/internal/style/style_test.go b/internal/style/style_test.go new file mode 100644 index 0000000..576d0ce --- /dev/null +++ b/internal/style/style_test.go @@ -0,0 +1,31 @@ +package style + +import "testing" + +func TestHyperlinkColorsDisabled(t *testing.T) { + s := NewWithColor(false) + got := s.Hyperlink("PR #42", "https://github.com/owner/repo/pull/42") + want := "PR #42 (https://github.com/owner/repo/pull/42)" + if got != want { + t.Errorf("Hyperlink fallback: got %q, want %q", got, want) + } +} + +func TestHyperlinkColorsEnabled(t *testing.T) { + s := NewWithColor(true) + got := s.Hyperlink("PR #42", "https://github.com/owner/repo/pull/42") + want := "\x1b]8;;https://github.com/owner/repo/pull/42\x1b\\PR #42\x1b]8;;\x1b\\" + if got != want { + t.Errorf("Hyperlink OSC 8: got %q, want %q", got, want) + } +} + +func TestHyperlinkEmptyURL(t *testing.T) { + for _, enabled := range []bool{false, true} { + s := NewWithColor(enabled) + got := s.Hyperlink("PR #42", "") + if got != "PR #42" { + t.Errorf("Hyperlink with empty URL (enabled=%v): got %q, want %q", enabled, got, "PR #42") + } + } +}