diff --git a/view/html.go b/view/html.go
index bf206bcb8fa8c81095ecf0899ecd1f1148e3b8d6..31c0fdd7cbdfa706f67829e454c5c58a31f3b43a 100644
--- a/view/html.go
+++ b/view/html.go
@@ -143,10 +143,11 @@ func hyperlink(url, text string) string {
return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, text)
} else {
// Fallback to plain text format for unsupported terminals
+ // Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
if text == url {
- return fmt.Sprintf("<%s>", url)
+ return fmt.Sprintf("<%s>", url)
}
- return fmt.Sprintf("%s <%s>", text, url)
+ return fmt.Sprintf("%s <%s>", text, url)
}
}
diff --git a/view/html_test.go b/view/html_test.go
index 1d784cadce500e0ee7690867f01c39fb33302e39..d74ae506b6c6e38a4921ff0d33734eb43610e234 100644
--- a/view/html_test.go
+++ b/view/html_test.go
@@ -78,144 +78,197 @@ func TestMarkdownToHTML(t *testing.T) {
}
}
-func TestGhosttySupported(t *testing.T) {
- // Save original environment variables
- origTerm := os.Getenv("TERM")
- origTermProgram := os.Getenv("TERM_PROGRAM")
- origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
-
- // Restore environment variables after test
- defer func() {
- os.Setenv("TERM", origTerm)
- os.Setenv("TERM_PROGRAM", origTermProgram)
- os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
- }()
-
+func TestHyperlinkSupported(t *testing.T) {
testCases := []struct {
- name string
- term string
- termProgram string
- ghosttyResourcesDir string
- expected bool
+ name string
+ term string
+ termProgram string
+ vteVersion string
+ kittyWindowID string
+ ghosttyResources string
+ weztermExecutable string
+ expected bool
}{
{
- name: "No Ghostty environment variables",
- term: "xterm",
- termProgram: "",
- ghosttyResourcesDir: "",
- expected: false,
+ name: "Kitty terminal",
+ term: "xterm-kitty",
+ expected: true,
+ },
+ {
+ name: "Ghostty terminal",
+ term: "ghostty",
+ expected: true,
},
{
- name: "TERM contains ghostty",
- term: "xterm-ghostty",
- termProgram: "",
- ghosttyResourcesDir: "",
- expected: true,
+ name: "WezTerm terminal",
+ term: "wezterm",
+ expected: true,
},
{
- name: "TERM_PROGRAM is ghostty",
- term: "xterm",
- termProgram: "ghostty",
- ghosttyResourcesDir: "",
- expected: true,
+ name: "Alacritty terminal",
+ term: "alacritty",
+ expected: true,
},
{
- name: "GHOSTTY_RESOURCES_DIR is set",
- term: "xterm",
- termProgram: "",
- ghosttyResourcesDir: "/usr/share/ghostty",
- expected: true,
+ name: "iTerm2 via TERM_PROGRAM",
+ term: "xterm-256color",
+ termProgram: "iTerm.app",
+ expected: true,
},
{
- name: "Multiple Ghostty indicators",
- term: "ghostty",
- termProgram: "ghostty",
- ghosttyResourcesDir: "/usr/share/ghostty",
- expected: true,
+ name: "VTE-based terminal",
+ term: "xterm-256color",
+ vteVersion: "0.64.2",
+ expected: true,
+ },
+ {
+ name: "Kitty via env var",
+ term: "xterm-256color",
+ kittyWindowID: "1",
+ expected: true,
+ },
+ {
+ name: "Ghostty via env var",
+ term: "xterm-256color",
+ ghosttyResources: "/opt/ghostty",
+ expected: true,
+ },
+ {
+ name: "WezTerm via env var",
+ term: "xterm-256color",
+ weztermExecutable: "/usr/bin/wezterm",
+ expected: true,
+ },
+ {
+ name: "Unsupported terminal",
+ term: "xterm",
+ expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
+ // Save original env vars
+ origTerm := os.Getenv("TERM")
+ origTermProgram := os.Getenv("TERM_PROGRAM")
+ origVteVersion := os.Getenv("VTE_VERSION")
+ origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
+ origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
+ origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
+
+ // Set test env vars
os.Setenv("TERM", tc.term)
os.Setenv("TERM_PROGRAM", tc.termProgram)
- os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResourcesDir)
+ os.Setenv("VTE_VERSION", tc.vteVersion)
+ os.Setenv("KITTY_WINDOW_ID", tc.kittyWindowID)
+ os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResources)
+ os.Setenv("WEZTERM_EXECUTABLE", tc.weztermExecutable)
+
+ // Test the function
+ result := hyperlinkSupported()
+
+ // Restore original env vars
+ os.Setenv("TERM", origTerm)
+ os.Setenv("TERM_PROGRAM", origTermProgram)
+ os.Setenv("VTE_VERSION", origVteVersion)
+ os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
+ os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
+ os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
- result := ghosttySupported()
if result != tc.expected {
- t.Errorf("Expected %t, got %t", tc.expected, result)
+ t.Errorf("Expected %v, got %v", tc.expected, result)
}
})
}
}
-func TestImageProtocolSupported(t *testing.T) {
- // Save original environment variables
- origTerm := os.Getenv("TERM")
- origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
- origTermProgram := os.Getenv("TERM_PROGRAM")
- origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
-
- // Restore environment variables after test
- defer func() {
- os.Setenv("TERM", origTerm)
- os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
- os.Setenv("TERM_PROGRAM", origTermProgram)
- os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
- }()
-
+func TestHyperlink(t *testing.T) {
testCases := []struct {
- name string
- term string
- kittyWindow string
- termProgram string
- ghosttyResourcesDir string
- expected bool
+ name string
+ url string
+ text string
+ hyperlinkSupported bool
+ expected string
}{
{
- name: "No supported terminals",
- term: "xterm",
- kittyWindow: "",
- termProgram: "",
- ghosttyResourcesDir: "",
- expected: false,
+ name: "Supported terminal with different text",
+ url: "https://example.com",
+ text: "Click here",
+ hyperlinkSupported: true,
+ expected: "\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07",
+ },
+ {
+ name: "Supported terminal with same text as URL",
+ url: "https://example.com",
+ text: "https://example.com",
+ hyperlinkSupported: true,
+ expected: "\x1b]8;;https://example.com\x07https://example.com\x1b]8;;\x07",
},
{
- name: "Kitty supported",
- term: "xterm-kitty",
- kittyWindow: "",
- termProgram: "",
- ghosttyResourcesDir: "",
- expected: true,
+ name: "Supported terminal with empty text",
+ url: "https://example.com",
+ text: "",
+ hyperlinkSupported: true,
+ expected: "\x1b]8;;https://example.com\x07https://example.com\x1b]8;;\x07",
},
{
- name: "Ghostty supported",
- term: "xterm",
- kittyWindow: "",
- termProgram: "ghostty",
- ghosttyResourcesDir: "",
- expected: true,
+ name: "Unsupported terminal with different text",
+ url: "https://example.com",
+ text: "Click here",
+ hyperlinkSupported: false,
+ expected: "Click here <https://example.com>",
},
{
- name: "Both Kitty and Ghostty supported",
- term: "xterm-kitty",
- kittyWindow: "1",
- termProgram: "ghostty",
- ghosttyResourcesDir: "/usr/share/ghostty",
- expected: true,
+ name: "Unsupported terminal with same text as URL",
+ url: "https://example.com",
+ text: "https://example.com",
+ hyperlinkSupported: false,
+ expected: "<https://example.com>",
+ },
+ {
+ name: "Unsupported terminal with empty text",
+ url: "https://example.com",
+ text: "",
+ hyperlinkSupported: false,
+ expected: "<https://example.com>",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- os.Setenv("TERM", tc.term)
- os.Setenv("KITTY_WINDOW_ID", tc.kittyWindow)
- os.Setenv("TERM_PROGRAM", tc.termProgram)
- os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResourcesDir)
+ // Save original env vars
+ origTerm := os.Getenv("TERM")
+ origTermProgram := os.Getenv("TERM_PROGRAM")
+ origVteVersion := os.Getenv("VTE_VERSION")
+ origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
+ origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
+ origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
+
+ // Set env to control hyperlink support
+ if tc.hyperlinkSupported {
+ os.Setenv("TERM", "xterm-kitty")
+ } else {
+ // Clear all environment variables that could indicate hyperlink support
+ os.Setenv("TERM", "xterm")
+ os.Unsetenv("TERM_PROGRAM")
+ os.Unsetenv("VTE_VERSION")
+ os.Unsetenv("KITTY_WINDOW_ID")
+ os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+ os.Unsetenv("WEZTERM_EXECUTABLE")
+ }
+
+ result := hyperlink(tc.url, tc.text)
+
+ // Restore original env vars
+ os.Setenv("TERM", origTerm)
+ os.Setenv("TERM_PROGRAM", origTermProgram)
+ os.Setenv("VTE_VERSION", origVteVersion)
+ os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
+ os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
+ os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
- result := imageProtocolSupported()
if result != tc.expected {
- t.Errorf("Expected %t, got %t", tc.expected, result)
+ t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
@@ -244,7 +297,7 @@ func TestProcessBody(t *testing.T) {
{
name: "With image HTML",
input: `
`,
- expected: "[Click here to view image: alt text]",
+ expected: "[Image: alt text, http://example.com/img.png]",
},
{
name: "With headers HTML",
@@ -258,8 +311,8 @@ func TestProcessBody(t *testing.T) {
},
{
name: "With image Markdown",
- input: `>`,
- expected: "[Click here to view image: alt text]",
+ input: ``,
+ expected: "[Image: alt text, http://example.com/img.png]",
},
{
name: "With headers Markdown",
@@ -281,3 +334,228 @@ func TestProcessBody(t *testing.T) {
})
}
}
+
+func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
+ h1Style := lipgloss.NewStyle()
+ h2Style := lipgloss.NewStyle()
+ bodyStyle := lipgloss.NewStyle()
+
+ testCases := []struct {
+ name string
+ input string
+ hyperlinkSupported bool
+ expectedContains []string
+ expectedNotContains []string
+ }{
+ {
+ name: "Link with hyperlink support",
+ input: `Click here`,
+ hyperlinkSupported: true,
+ expectedContains: []string{"\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07"},
+ expectedNotContains: []string{""},
+ },
+ {
+ name: "Link without hyperlink support",
+ input: `Click here`,
+ hyperlinkSupported: false,
+ expectedContains: []string{"Click here "},
+ expectedNotContains: []string{"\x1b]8;;"},
+ },
+ {
+ name: "Image with hyperlink support",
+ input: `
`,
+ hyperlinkSupported: true,
+ expectedContains: []string{"\x1b]8;;https://example.com/image.png\x07", "[Click here to view image: Test image]"},
+ expectedNotContains: []string{""},
+ },
+ {
+ name: "Image without hyperlink support",
+ input: `
`,
+ hyperlinkSupported: false,
+ expectedContains: []string{"[Image: Test image, https://example.com/image.png]"},
+ expectedNotContains: []string{"\x1b]8;;", "[Click here to view image:"},
+ },
+ {
+ name: "Markdown link with hyperlink support",
+ input: `[Visit our site](https://example.com)`,
+ hyperlinkSupported: true,
+ expectedContains: []string{"\x1b]8;;https://example.com\x07Visit our site\x1b]8;;\x07"},
+ expectedNotContains: []string{""},
+ },
+ {
+ name: "Markdown link without hyperlink support",
+ input: `[Visit our site](https://example.com)`,
+ hyperlinkSupported: false,
+ expectedContains: []string{"Visit our site "},
+ expectedNotContains: []string{"\x1b]8;;"},
+ },
+ {
+ name: "Markdown image with hyperlink support",
+ input: ``,
+ hyperlinkSupported: true,
+ expectedContains: []string{"\x1b]8;;https://example.com/sunset.jpg\x07", "[Click here to view image: Beautiful sunset]"},
+ expectedNotContains: []string{""},
+ },
+ {
+ name: "Markdown image without hyperlink support",
+ input: ``,
+ hyperlinkSupported: false,
+ expectedContains: []string{"[Image: Beautiful sunset, https://example.com/sunset.jpg]"},
+ expectedNotContains: []string{"\x1b]8;;", "[Click here to view image:"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Save original env vars
+ origTerm := os.Getenv("TERM")
+ origTermProgram := os.Getenv("TERM_PROGRAM")
+ origVteVersion := os.Getenv("VTE_VERSION")
+ origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
+ origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
+ origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
+
+ // Set env to control hyperlink support
+ if tc.hyperlinkSupported {
+ os.Setenv("TERM", "xterm-kitty")
+ } else {
+ // Clear all environment variables that could indicate hyperlink support
+ os.Setenv("TERM", "xterm")
+ os.Unsetenv("TERM_PROGRAM")
+ os.Unsetenv("VTE_VERSION")
+ os.Unsetenv("KITTY_WINDOW_ID")
+ os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+ os.Unsetenv("WEZTERM_EXECUTABLE")
+ }
+
+ processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+
+ // Restore original env vars
+ os.Setenv("TERM", origTerm)
+ os.Setenv("TERM_PROGRAM", origTermProgram)
+ os.Setenv("VTE_VERSION", origVteVersion)
+ os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
+ os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
+ os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
+
+ if err != nil {
+ t.Fatalf("ProcessBody() failed: %v", err)
+ }
+
+ // Check that expected strings are present
+ for _, expected := range tc.expectedContains {
+ if !strings.Contains(processed, expected) {
+ t.Errorf("Expected processed body to contain %q, but it didn't.\nProcessed: %q", expected, processed)
+ }
+ }
+
+ // Check that unexpected strings are not present
+ for _, notExpected := range tc.expectedNotContains {
+ if strings.Contains(processed, notExpected) {
+ t.Errorf("Expected processed body to NOT contain %q, but it did.\nProcessed: %q", notExpected, processed)
+ }
+ }
+ })
+ }
+}
+
+func TestImageAndLinkFallbackBehavior(t *testing.T) {
+ h1Style := lipgloss.NewStyle()
+ h2Style := lipgloss.NewStyle()
+ bodyStyle := lipgloss.NewStyle()
+
+ testCases := []struct {
+ name string
+ input string
+ hyperlinkSupported bool
+ imageProtocolSupported bool
+ expectedContains []string
+ expectedNotContains []string
+ }{
+ {
+ name: "Full support - both image protocol and hyperlinks",
+ input: `
and link`,
+ hyperlinkSupported: true,
+ imageProtocolSupported: true,
+ expectedContains: []string{"\x1b]8;;https://example.com\x07link\x1b]8;;\x07", "\x1b]8;;https://example.com/image.png\x07", "[Click here to view image: Test image]"},
+ expectedNotContains: []string{"", "[Image:"},
+ },
+ {
+ name: "Hyperlink support only - no image protocol",
+ input: `
and link`,
+ hyperlinkSupported: true,
+ imageProtocolSupported: false,
+ expectedContains: []string{"\x1b]8;;https://example.com\x07link\x1b]8;;\x07", "\x1b]8;;https://example.com/image.png\x07", "[Click here to view image: Test image]"},
+ expectedNotContains: []string{"", "[Image:"},
+ },
+ {
+ name: "No support - plain text fallback",
+ input: `
and link`,
+ hyperlinkSupported: false,
+ imageProtocolSupported: false,
+ expectedContains: []string{"link ", "[Image: Test image, https://example.com/image.png]"},
+ expectedNotContains: []string{"\x1b]8;;", "[Click here to view image:"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Save original env vars
+ origTerm := os.Getenv("TERM")
+ origTermProgram := os.Getenv("TERM_PROGRAM")
+ origVteVersion := os.Getenv("VTE_VERSION")
+ origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
+ origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
+ origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
+
+ // Set environment for hyperlink support
+ if tc.hyperlinkSupported {
+ os.Setenv("TERM", "xterm-kitty")
+ } else {
+ os.Setenv("TERM", "xterm")
+ os.Unsetenv("TERM_PROGRAM")
+ os.Unsetenv("VTE_VERSION")
+ os.Unsetenv("KITTY_WINDOW_ID")
+ os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+ os.Unsetenv("WEZTERM_EXECUTABLE")
+ }
+
+ // Set environment for image protocol support
+ if tc.imageProtocolSupported && !tc.hyperlinkSupported {
+ // This case shouldn't happen in practice, but test it anyway
+ os.Setenv("KITTY_WINDOW_ID", "1")
+ } else if !tc.imageProtocolSupported {
+ os.Unsetenv("KITTY_WINDOW_ID")
+ os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+ }
+
+ processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+
+ // Restore original env vars
+ os.Setenv("TERM", origTerm)
+ os.Setenv("TERM_PROGRAM", origTermProgram)
+ os.Setenv("VTE_VERSION", origVteVersion)
+ os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
+ os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
+ os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
+
+ if err != nil {
+ t.Fatalf("ProcessBody() failed: %v", err)
+ }
+
+ // Check that expected strings are present
+ for _, expected := range tc.expectedContains {
+ if !strings.Contains(processed, expected) {
+ t.Errorf("Expected processed body to contain %q, but it didn't.\nProcessed: %q", expected, processed)
+ }
+ }
+
+ // Check that unexpected strings are not present
+ for _, notExpected := range tc.expectedNotContains {
+ if strings.Contains(processed, notExpected) {
+ t.Errorf("Expected processed body to NOT contain %q, but it did.\nProcessed: %q", notExpected, processed)
+ }
+ }
+ })
+ }
+}