From 745ce823b4920457464b6b02bafa07d41024102c Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sun, 4 Jan 2026 18:21:50 +0400 Subject: [PATCH] feat(image protocol): support for the terminals (see description) (#95) --- view/html.go | 130 ++++++++++- view/html_test.go | 550 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 598 insertions(+), 82 deletions(-) diff --git a/view/html.go b/view/html.go index 31c0fdd7cbdfa706f67829e454c5c58a31f3b43a..dc8cff8164c74747ff52e6b41e2261ddddc87ec6 100644 --- a/view/html.go +++ b/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: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))) diff --git a/view/html_test.go b/view/html_test.go index d74ae506b6c6e38a4921ff0d33734eb43610e234..1b8cd1041566a0ac21de3c376fc982d9f4113288 100644 --- a/view/html_test.go +++ b/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 <https://example.com>", + 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: "<https://example.com>", + 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: "<https://example.com>", + 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: `Click here`, + expectedContains: "Click here", + expectedNotContains: "<http://example.com>", + }, + { + name: "Link without hyperlink support", + setupHyperlinks: func() { + clearAllTerminalEnv() + }, + input: `Click here`, + expectedContains: "Click here ", + }, + { + 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: `alt text`, + expectedContains: "[Click here to view image: alt text]", + expectedNotContains: "<http://example.com/img.png>", + }, + { + name: "Image link without hyperlink support", + setupHyperlinks: func() { + clearAllTerminalEnv() + }, + input: `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: `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: `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: `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: `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: `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: "

Hello, world!

", expected: "Hello, world!", }, - { - name: "With link HTML", - input: `Click here`, - expected: "Click here", - }, - { - name: "With image HTML", - input: `alt text`, - expected: "[Image: alt text, http://example.com/img.png]", - }, { name: "With headers HTML", input: "

Header 1

", 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 {