Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 26 additions & 2 deletions internal/style/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
// All methods return plain text when colors are disabled.
type Style struct {
enabled bool
isTTY bool
}

var (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
31 changes: 31 additions & 0 deletions internal/style/style_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading