From d2715fa256604e1c1ae1ef015f9aa81d406386a1 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sun, 4 Jan 2026 17:57:52 +0400 Subject: [PATCH] feat(image protocol): support for ghostty (#93) --- view/html.go | 68 +++++++++++++++------- view/html_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 21 deletions(-) diff --git a/view/html.go b/view/html.go index 7b8004c7bec19bb5a644d89de6d94693d4b7282d..bf206bcb8fa8c81095ecf0899ecd1f1148e3b8d6 100644 --- a/view/html.go +++ b/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))) diff --git a/view/html_test.go b/view/html_test.go index b0d427c5c5ba427cad747d4b6e8a5f25e8239490..1d784cadce500e0ee7690867f01c39fb33302e39 100644 --- a/view/html_test.go +++ b/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")