feat(image protocol): support for the terminals (see description) (#95)

Drew Smirnoff created

Change summary

view/html.go      | 130 +++++++++++
view/html_test.go | 550 ++++++++++++++++++++++++++++++++++++++++++------
2 files changed, 598 insertions(+), 82 deletions(-)

Detailed changes

view/html.go 🔗

@@ -198,9 +198,85 @@ func ghosttySupported() bool {
 	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
 }
 
+func iterm2Supported() bool {
+	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+	if termProgram == "iterm.app" {
+		return true
+	}
+
+	// Check for iTerm2-specific environment variables
+	if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
+		return true
+	}
+
+	return false
+}
+
+func weztermSupported() bool {
+	// Check for WezTerm-specific environment variables
+	if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
+		return true
+	}
+
+	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+	if termProgram == "wezterm" {
+		return true
+	}
+
+	term := strings.ToLower(os.Getenv("TERM"))
+	if strings.Contains(term, "wezterm") {
+		return true
+	}
+
+	return false
+}
+
+func waystSupported() bool {
+	term := strings.ToLower(os.Getenv("TERM"))
+	if strings.Contains(term, "wayst") {
+		return true
+	}
+
+	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+	if termProgram == "wayst" {
+		return true
+	}
+
+	return false
+}
+
+func warpSupported() bool {
+	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+	if termProgram == "warp" {
+		return true
+	}
+
+	// Check for Warp-specific environment variables
+	if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
+		return true
+	}
+
+	return false
+}
+
+func konsoleSupported() bool {
+	// Check for Konsole-specific environment variables
+	if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
+		return true
+	}
+
+	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
+	if termProgram == "konsole" {
+		return true
+	}
+
+	return false
+}
+
 // imageProtocolSupported checks if any supported image protocol terminal is detected.
 func imageProtocolSupported() bool {
-	return kittySupported() || ghosttySupported()
+	return kittySupported() || ghosttySupported() || iterm2Supported() ||
+		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
 }
 
 func debugImageProtocol(format string, args ...interface{}) {
@@ -326,6 +402,52 @@ func kittyInlineImage(payload string) string {
 	return b.String()
 }
 
+// iterm2InlineImage renders an image using iTerm2's image protocol
+func iterm2InlineImage(payload string) string {
+	if payload == "" {
+		return ""
+	}
+
+	// Calculate rows for cursor positioning
+	rows := 1
+	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
+		if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
+			cellHeight := getTerminalCellSize()
+			h := img.Bounds().Dy()
+			rows = (h + cellHeight - 1) / cellHeight
+			if rows < 1 {
+				rows = 1
+			}
+			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
+		}
+	}
+
+	// iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
+	result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
+
+	// Add placeholder for row spacing
+	result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
+
+	return result
+}
+
+// renderInlineImage renders an image using the appropriate protocol for the detected terminal
+func renderInlineImage(payload string) string {
+	if payload == "" {
+		return ""
+	}
+
+	if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
+		// These terminals use the Kitty graphics protocol
+		return kittyInlineImage(payload)
+	} else if iterm2Supported() || warpSupported() {
+		// iTerm2 and Warp use the iTerm2 image protocol
+		return iterm2InlineImage(payload)
+	}
+
+	return ""
+}
+
 // expandImageRowPlaceholders replaces image row placeholders with actual newlines.
 func expandImageRowPlaceholders(text string) string {
 	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
@@ -441,8 +563,8 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 			}
 
 			if payload != "" {
-				if rendered := kittyInlineImage(payload); rendered != "" {
-					debugImageProtocol("rendered inline image src=%s len=%d dataURI=%t cid=%t (kitty=%t ghostty=%t)", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"), kittySupported(), ghosttySupported())
+				if rendered := renderInlineImage(payload); rendered != "" {
+					debugImageProtocol("rendered inline image src=%s len=%d dataURI=%t cid=%t (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"), kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
 					s.ReplaceWithHtml("\n" + rendered + "\n")
 					return
 				}
@@ -451,7 +573,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 				debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
 			}
 		} else {
-			debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t)", src, kittySupported(), ghosttySupported())
+			debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
 		}
 		if hyperlinkSupported() {
 			s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))

view/html_test.go 🔗

@@ -8,6 +8,26 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
+// clearAllTerminalEnv clears all environment variables that could indicate terminal capabilities
+func clearAllTerminalEnv() {
+	// Clear hyperlink support indicators
+	os.Unsetenv("VTE_VERSION")
+	os.Unsetenv("KITTY_WINDOW_ID")
+	os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+	os.Unsetenv("WEZTERM_EXECUTABLE")
+	os.Unsetenv("WEZTERM_CONFIG_FILE")
+	os.Unsetenv("ITERM_SESSION_ID")
+	os.Unsetenv("ITERM_PROFILE")
+	os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+	os.Unsetenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED")
+	os.Unsetenv("KONSOLE_DBUS_SESSION")
+	os.Unsetenv("KONSOLE_VERSION")
+
+	// Set basic terminal that doesn't support anything special
+	os.Setenv("TERM", "xterm")
+	os.Setenv("TERM_PROGRAM", "basic")
+}
+
 func TestDecodeQuotedPrintable(t *testing.T) {
 	testCases := []struct {
 		name     string
@@ -182,93 +202,482 @@ func TestHyperlinkSupported(t *testing.T) {
 	}
 }
 
-func TestHyperlink(t *testing.T) {
+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")
+	origItermlSession := os.Getenv("ITERM_SESSION_ID")
+	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
+	origWarpLocal := os.Getenv("WARP_IS_LOCAL_SHELL_SESSION")
+	origKonsoleDBus := os.Getenv("KONSOLE_DBUS_SESSION")
+
+	// 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)
+		os.Setenv("ITERM_SESSION_ID", origItermlSession)
+		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
+		os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", origWarpLocal)
+		os.Setenv("KONSOLE_DBUS_SESSION", origKonsoleDBus)
+	}()
+
 	testCases := []struct {
-		name               string
-		url                string
-		text               string
-		hyperlinkSupported bool
-		expected           string
+		name        string
+		setupEnv    func()
+		clearAllEnv func()
+		expected    bool
 	}{
 		{
-			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: "No supported terminals",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("TERM_PROGRAM", "basic")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			expected: false,
 		},
 		{
-			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 via TERM",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm-kitty")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			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: "Kitty supported via KITTY_WINDOW_ID",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("KITTY_WINDOW_ID", "1")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			expected: true,
 		},
 		{
-			name:               "Unsupported terminal with different text",
-			url:                "https://example.com",
-			text:               "Click here",
-			hyperlinkSupported: false,
-			expected:           "Click here &lt;https://example.com&gt;",
+			name: "Ghostty supported via TERM_PROGRAM",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("TERM_PROGRAM", "ghostty")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			expected: true,
 		},
 		{
-			name:               "Unsupported terminal with same text as URL",
-			url:                "https://example.com",
-			text:               "https://example.com",
-			hyperlinkSupported: false,
-			expected:           "&lt;https://example.com&gt;",
+			name: "iTerm2 supported via TERM_PROGRAM",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("TERM_PROGRAM", "iterm.app")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			expected: true,
 		},
 		{
-			name:               "Unsupported terminal with empty text",
-			url:                "https://example.com",
-			text:               "",
-			hyperlinkSupported: false,
-			expected:           "&lt;https://example.com&gt;",
+			name: "WezTerm supported via WEZTERM_EXECUTABLE",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			expected: true,
+		},
+		{
+			name: "Warp supported via WARP_IS_LOCAL_SHELL_SESSION",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", "1")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("KONSOLE_DBUS_SESSION")
+			},
+			expected: true,
+		},
+		{
+			name: "Konsole supported via KONSOLE_DBUS_SESSION",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("KONSOLE_DBUS_SESSION", "/Sessions/1")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
+			},
+			expected: true,
 		},
 	}
 
 	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")
+			tc.clearAllEnv()
+			tc.setupEnv()
 
-			// Set env to control hyperlink support
-			if tc.hyperlinkSupported {
+			if result != tc.expected {
+				t.Errorf("Expected %q, got %q", tc.expected, result)
+			}
+		})
+	}
+}
+
+func TestHyperlinkSupported(t *testing.T) {
+	// Save original environment variables
+	origTerm := os.Getenv("TERM")
+	origTermProgram := os.Getenv("TERM_PROGRAM")
+	origVTEVersion := os.Getenv("VTE_VERSION")
+	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
+	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
+	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
+
+	// Restore environment variables after test
+	defer func() {
+		os.Setenv("TERM", origTerm)
+		os.Setenv("TERM_PROGRAM", origTermProgram)
+		os.Setenv("VTE_VERSION", origVTEVersion)
+		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
+		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
+		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
+	}()
+
+	testCases := []struct {
+		name        string
+		setupEnv    func()
+		clearAllEnv func()
+		expected    bool
+	}{
+		{
+			name: "No hyperlink support",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("TERM_PROGRAM", "basic")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("VTE_VERSION")
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+			},
+			expected: false,
+		},
+		{
+			name: "Kitty hyperlink support via TERM",
+			setupEnv: func() {
 				os.Setenv("TERM", "xterm-kitty")
-			} else {
-				// Clear all environment variables that could indicate hyperlink support
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("VTE_VERSION")
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+			},
+			expected: true,
+		},
+		{
+			name: "VTE-based terminal hyperlink support",
+			setupEnv: func() {
 				os.Setenv("TERM", "xterm")
-				os.Unsetenv("TERM_PROGRAM")
+				os.Setenv("VTE_VERSION", "0.60.3")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+			},
+			expected: true,
+		},
+		{
+			name: "iTerm2 hyperlink support",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("TERM_PROGRAM", "iterm.app")
+			},
+			clearAllEnv: func() {
 				os.Unsetenv("VTE_VERSION")
 				os.Unsetenv("KITTY_WINDOW_ID")
 				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
 				os.Unsetenv("WEZTERM_EXECUTABLE")
+			},
+			expected: true,
+		},
+		{
+			name: "WezTerm hyperlink support",
+			setupEnv: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
+			},
+			clearAllEnv: func() {
+				os.Unsetenv("VTE_VERSION")
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+			},
+			expected: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc.clearAllEnv()
+			tc.setupEnv()
+
+			result := hyperlinkSupported()
+			if result != tc.expected {
+				t.Errorf("Expected %t, got %t", tc.expected, result)
 			}
+		})
+	}
+}
 
-			result := hyperlink(tc.url, tc.text)
+func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
+	// Save original environment variables
+	origTerm := os.Getenv("TERM")
+	origTermProgram := os.Getenv("TERM_PROGRAM")
+	origVTEVersion := os.Getenv("VTE_VERSION")
+	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
+
+	// Restore environment variables after test
+	defer func() {
+		os.Setenv("TERM", origTerm)
+		os.Setenv("TERM_PROGRAM", origTermProgram)
+		os.Setenv("VTE_VERSION", origVTEVersion)
+		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
+	}()
 
-			// 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)
+	h1Style := lipgloss.NewStyle().SetString("H1")
+	h2Style := lipgloss.NewStyle().SetString("H2")
+	bodyStyle := lipgloss.NewStyle().SetString("BODY")
 
-			if result != tc.expected {
-				t.Errorf("Expected %q, got %q", tc.expected, result)
+	testCases := []struct {
+		name                string
+		setupHyperlinks     func()
+		input               string
+		expectedContains    string
+		expectedNotContains string
+	}{
+		{
+			name: "Link with hyperlink support",
+			setupHyperlinks: func() {
+				os.Setenv("TERM", "xterm-kitty")
+				os.Unsetenv("VTE_VERSION")
+				os.Unsetenv("KITTY_WINDOW_ID")
+			},
+			input:               `<a href="http://example.com">Click here</a>`,
+			expectedContains:    "Click here",
+			expectedNotContains: "&lt;http://example.com&gt;",
+		},
+		{
+			name: "Link without hyperlink support",
+			setupHyperlinks: func() {
+				clearAllTerminalEnv()
+			},
+			input:            `<a href="http://example.com">Click here</a>`,
+			expectedContains: "Click here <http://example.com>",
+		},
+		{
+			name: "Image link with hyperlink support",
+			setupHyperlinks: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("VTE_VERSION", "0.60.3")
+				os.Unsetenv("KITTY_WINDOW_ID")
+			},
+			input:               `<img src="http://example.com/img.png" alt="alt text">`,
+			expectedContains:    "[Click here to view image: alt text]",
+			expectedNotContains: "&lt;http://example.com/img.png&gt;",
+		},
+		{
+			name: "Image link without hyperlink support",
+			setupHyperlinks: func() {
+				clearAllTerminalEnv()
+			},
+			input:            `<img src="http://example.com/img.png" alt="alt text">`,
+			expectedContains: "[Image: alt text, http://example.com/img.png]",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc.setupHyperlinks()
+
+			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+			if err != nil {
+				t.Fatalf("ProcessBody() failed: %v", err)
+			}
+
+			if !strings.Contains(processed, tc.expectedContains) {
+				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expectedContains)
+			}
+
+			if tc.expectedNotContains != "" && strings.Contains(processed, tc.expectedNotContains) {
+				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", processed, tc.expectedNotContains)
+			}
+		})
+	}
+}
+
+func TestProcessBodyWithImageProtocol(t *testing.T) {
+	// Save original environment variables
+	origTerm := os.Getenv("TERM")
+	origTermProgram := os.Getenv("TERM_PROGRAM")
+	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
+	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
+	origItermlSession := os.Getenv("ITERM_SESSION_ID")
+	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
+
+	// Restore environment variables after test
+	defer func() {
+		os.Setenv("TERM", origTerm)
+		os.Setenv("TERM_PROGRAM", origTermProgram)
+		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
+		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
+		os.Setenv("ITERM_SESSION_ID", origItermlSession)
+		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
+	}()
+
+	h1Style := lipgloss.NewStyle().SetString("H1")
+	h2Style := lipgloss.NewStyle().SetString("H2")
+	bodyStyle := lipgloss.NewStyle().SetString("BODY")
+
+	// Create a simple base64 PNG image (1x1 pixel white PNG)
+	testBase64PNG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
+
+	testCases := []struct {
+		name                string
+		setupImageProtocol  func()
+		clearAllImageEnv    func()
+		input               string
+		expectedContains    string
+		expectedNotContains string
+	}{
+		{
+			name: "Data URI image with Kitty support",
+			setupImageProtocol: func() {
+				os.Setenv("TERM", "xterm-kitty")
+			},
+			clearAllImageEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+			},
+			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
+			expectedContains:    "\x1b_Gf=100,a=T,q=2,C=1,m=0;",
+			expectedNotContains: "[Image: test image,",
+		},
+		{
+			name: "Data URI image with iTerm2 support",
+			setupImageProtocol: func() {
+				os.Setenv("TERM", "xterm")
+				os.Setenv("TERM_PROGRAM", "iterm.app")
+			},
+			clearAllImageEnv: func() {
+				os.Unsetenv("KITTY_WINDOW_ID")
+				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
+				os.Unsetenv("ITERM_SESSION_ID")
+				os.Unsetenv("WEZTERM_EXECUTABLE")
+			},
+			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
+			expectedContains:    "\x1b]1337;File=inline=1:",
+			expectedNotContains: "[Image: test image,",
+		},
+		{
+			name: "Data URI image without protocol support",
+			setupImageProtocol: func() {
+				clearAllTerminalEnv()
+			},
+			clearAllImageEnv: func() {
+				// This is handled by clearAllTerminalEnv now
+			},
+			input:            `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
+			expectedContains: "[Image: test image,",
+		},
+		{
+			name: "Remote image with WezTerm support (has hyperlink support)",
+			setupImageProtocol: func() {
+				clearAllTerminalEnv()
+				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
+			},
+			clearAllImageEnv: func() {
+				// This is handled by clearAllTerminalEnv now
+			},
+			input:            `<img src="http://example.com/img.png" alt="remote image">`,
+			expectedContains: "[Click here to view image: remote image]", // Remote images won't render without actual fetch, but hyperlinks work
+		},
+		{
+			name: "Remote image without protocol support",
+			setupImageProtocol: func() {
+				clearAllTerminalEnv()
+			},
+			clearAllImageEnv: func() {
+				// This is handled by clearAllTerminalEnv now
+			},
+			input:            `<img src="http://example.com/img.png" alt="remote image">`,
+			expectedContains: "[Image: remote image,",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc.clearAllImageEnv()
+			tc.setupImageProtocol()
+
+			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+			if err != nil {
+				t.Fatalf("ProcessBody() failed: %v", err)
+			}
+
+			if !strings.Contains(processed, tc.expectedContains) {
+				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expectedContains)
+			}
+
+			if tc.expectedNotContains != "" && strings.Contains(processed, tc.expectedNotContains) {
+				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", processed, tc.expectedNotContains)
 			}
 		})
 	}
@@ -289,36 +698,21 @@ func TestProcessBody(t *testing.T) {
 			input:    "<p>Hello, world!</p>",
 			expected: "Hello, world!",
 		},
-		{
-			name:     "With link HTML",
-			input:    `<a href="http://example.com">Click here</a>`,
-			expected: "Click here",
-		},
-		{
-			name:     "With image HTML",
-			input:    `<img src="http://example.com/img.png" alt="alt text">`,
-			expected: "[Image: alt text, http://example.com/img.png]",
-		},
 		{
 			name:     "With headers HTML",
 			input:    "<h1>Header 1</h1>",
 			expected: "Header 1",
 		},
-		{
-			name:     "With link Markdown",
-			input:    `[Click here](http://example.com)`,
-			expected: "Click here",
-		},
-		{
-			name:     "With image Markdown",
-			input:    `![alt text](http://example.com/img.png)`,
-			expected: "[Image: alt text, http://example.com/img.png]",
-		},
 		{
 			name:     "With headers Markdown",
 			input:    "# Header 1",
 			expected: "Header 1",
 		},
+		{
+			name:     "Plain text",
+			input:    "Just plain text without any markup",
+			expected: "Just plain text without any markup",
+		},
 	}
 
 	for _, tc := range testCases {