feat(image protocol): support for ghostty (#93)

Drew Smirnoff created

Change summary

view/html.go      |  68 +++++++++++++++-------
view/html_test.go | 144 +++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 191 insertions(+), 21 deletions(-)

Detailed changes

view/html.go 🔗

@@ -47,7 +47,7 @@ func getTerminalCellSize() int {
 		}
 	}
 
-	debugKitty("using default cell height: %d pixels", defaultCellHeight)
+	debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
 	return defaultCellHeight
 }
 
@@ -65,14 +65,14 @@ func getCellHeightFromFd(fd int) int {
 	if ws.Row > 0 && ws.Ypixel > 0 {
 		cellHeight := int(ws.Ypixel) / int(ws.Row)
 		if cellHeight > 0 {
-			debugKitty("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
+			debugImageProtocol("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
 			return cellHeight
 		}
 	}
 
 	// Terminal reported dimensions but no pixel info - this is common
 	if ws.Row > 0 && ws.Ypixel == 0 {
-		debugKitty("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
+		debugImageProtocol("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
 	}
 
 	return 0
@@ -181,13 +181,39 @@ func kittySupported() bool {
 	return os.Getenv("KITTY_WINDOW_ID") != ""
 }
 
-func debugKitty(format string, args ...interface{}) {
-	if os.Getenv("DEBUG_KITTY_IMAGES") == "" {
+func ghosttySupported() bool {
+	// Check for TERM containing ghostty
+	term := strings.ToLower(os.Getenv("TERM"))
+	if strings.Contains(term, "ghostty") {
+		return true
+	}
+
+	// Check for Ghostty-specific environment variables
+	if os.Getenv("TERM_PROGRAM") == "ghostty" {
+		return true
+	}
+
+	// Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
+	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
+}
+
+// imageProtocolSupported checks if any supported image protocol terminal is detected.
+func imageProtocolSupported() bool {
+	return kittySupported() || ghosttySupported()
+}
+
+func debugImageProtocol(format string, args ...interface{}) {
+	if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
 		return
 	}
-	msg := fmt.Sprintf("[kitty-img] "+format+"\n", args...)
+	msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
 	fmt.Print(msg)
-	if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
+	if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
+		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
+			_, _ = f.WriteString(msg)
+			_ = f.Close()
+		}
+	} else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
 		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
 			_, _ = f.WriteString(msg)
 			_ = f.Close()
@@ -202,34 +228,34 @@ func fetchRemoteBase64(url string) string {
 	client := &http.Client{Timeout: 5 * time.Second}
 	resp, err := client.Get(url)
 	if err != nil {
-		debugKitty("remote fetch failed url=%s err=%v", url, err)
+		debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
 		return ""
 	}
 	defer resp.Body.Close()
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		debugKitty("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
+		debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
 		return ""
 	}
 	data, err := io.ReadAll(resp.Body)
 	if err != nil {
-		debugKitty("remote fetch read error url=%s err=%v", url, err)
+		debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
 		return ""
 	}
 
 	img, _, err := image.Decode(bytes.NewReader(data))
 	if err != nil {
-		debugKitty("remote decode failed url=%s err=%v", url, err)
+		debugImageProtocol("remote decode failed url=%s err=%v", url, err)
 		return ""
 	}
 
 	var buf bytes.Buffer
 	if err := png.Encode(&buf, img); err != nil {
-		debugKitty("remote png encode failed url=%s err=%v", url, err)
+		debugImageProtocol("remote png encode failed url=%s err=%v", url, err)
 		return ""
 	}
 
 	encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
-	debugKitty("remote fetch ok url=%s len=%d", url, len(encoded))
+	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
 	return encoded
 }
 
@@ -268,7 +294,7 @@ func kittyInlineImage(payload string) string {
 			if rows < 1 {
 				rows = 1
 			}
-			debugKitty("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
+			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 		}
 	}
 
@@ -396,7 +422,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 			alt = "Does not contain alt text"
 		}
 
-		if kittySupported() {
+		if imageProtocolSupported() {
 			var payload string
 			if strings.HasPrefix(src, "data:image/") {
 				payload = dataURIBase64(src)
@@ -405,9 +431,9 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 				cid = strings.Trim(cid, "<>")
 				if inline != nil {
 					payload = inline[cid]
-					debugKitty("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
+					debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
 				} else {
-					debugKitty("cid lookup skipped inline map nil for %s", cid)
+					debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
 				}
 			} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
 				payload = fetchRemoteBase64(src)
@@ -415,16 +441,16 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 
 			if payload != "" {
 				if rendered := kittyInlineImage(payload); rendered != "" {
-					debugKitty("rendered inline image src=%s len=%d dataURI=%t cid=%t", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
+					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())
 					s.ReplaceWithHtml("\n" + rendered + "\n")
 					return
 				}
-				debugKitty("payload present but renderer returned empty src=%s len=%d", src, len(payload))
+				debugImageProtocol("payload present but renderer returned empty src=%s len=%d", src, len(payload))
 			} else {
-				debugKitty("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
+				debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
 			}
 		} else {
-			debugKitty("kitty not detected for src=%s", src)
+			debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t)", src, kittySupported(), ghosttySupported())
 		}
 		if hyperlinkSupported() {
 			s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))

view/html_test.go 🔗

@@ -1,6 +1,7 @@
 package view
 
 import (
+	"os"
 	"strings"
 	"testing"
 
@@ -77,6 +78,149 @@ 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)
+	}()
+
+	testCases := []struct {
+		name                string
+		term                string
+		termProgram         string
+		ghosttyResourcesDir string
+		expected            bool
+	}{
+		{
+			name:                "No Ghostty environment variables",
+			term:                "xterm",
+			termProgram:         "",
+			ghosttyResourcesDir: "",
+			expected:            false,
+		},
+		{
+			name:                "TERM contains ghostty",
+			term:                "xterm-ghostty",
+			termProgram:         "",
+			ghosttyResourcesDir: "",
+			expected:            true,
+		},
+		{
+			name:                "TERM_PROGRAM is ghostty",
+			term:                "xterm",
+			termProgram:         "ghostty",
+			ghosttyResourcesDir: "",
+			expected:            true,
+		},
+		{
+			name:                "GHOSTTY_RESOURCES_DIR is set",
+			term:                "xterm",
+			termProgram:         "",
+			ghosttyResourcesDir: "/usr/share/ghostty",
+			expected:            true,
+		},
+		{
+			name:                "Multiple Ghostty indicators",
+			term:                "ghostty",
+			termProgram:         "ghostty",
+			ghosttyResourcesDir: "/usr/share/ghostty",
+			expected:            true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			os.Setenv("TERM", tc.term)
+			os.Setenv("TERM_PROGRAM", tc.termProgram)
+			os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResourcesDir)
+
+			result := ghosttySupported()
+			if result != tc.expected {
+				t.Errorf("Expected %t, got %t", 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)
+	}()
+
+	testCases := []struct {
+		name                string
+		term                string
+		kittyWindow         string
+		termProgram         string
+		ghosttyResourcesDir string
+		expected            bool
+	}{
+		{
+			name:                "No supported terminals",
+			term:                "xterm",
+			kittyWindow:         "",
+			termProgram:         "",
+			ghosttyResourcesDir: "",
+			expected:            false,
+		},
+		{
+			name:                "Kitty supported",
+			term:                "xterm-kitty",
+			kittyWindow:         "",
+			termProgram:         "",
+			ghosttyResourcesDir: "",
+			expected:            true,
+		},
+		{
+			name:                "Ghostty supported",
+			term:                "xterm",
+			kittyWindow:         "",
+			termProgram:         "ghostty",
+			ghosttyResourcesDir: "",
+			expected:            true,
+		},
+		{
+			name:                "Both Kitty and Ghostty supported",
+			term:                "xterm-kitty",
+			kittyWindow:         "1",
+			termProgram:         "ghostty",
+			ghosttyResourcesDir: "/usr/share/ghostty",
+			expected:            true,
+		},
+	}
+
+	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)
+
+			result := imageProtocolSupported()
+			if result != tc.expected {
+				t.Errorf("Expected %t, got %t", tc.expected, result)
+			}
+		})
+	}
+}
+
 func TestProcessBody(t *testing.T) {
 	h1Style := lipgloss.NewStyle().SetString("H1")
 	h2Style := lipgloss.NewStyle().SetString("H2")