feat: sixel fallback for multiplexers (#1011)

Drew Smirnoff created

Change summary

clib/sixelconv.go            |  31 +++++++++++
clib/sixelconv_test.go       |  29 ++++++++++
docs/docs/Features/Images.md |  10 +++
go.mod                       |   2 
go.sum                       |   4 +
view/html.go                 |  98 ++++++++++++++++++++++++++++++++--
view/html_test.go            | 106 ++++++++++++++++++++++++++++++++++++++
7 files changed, 274 insertions(+), 6 deletions(-)

Detailed changes

clib/sixelconv.go 🔗

@@ -0,0 +1,31 @@
+package clib
+
+import (
+	"bytes"
+	"image/png"
+
+	"github.com/mattn/go-sixel"
+)
+
+// EncodePNGToSixel converts PNG bytes to Sixel format
+// Returns Sixel sequence and row count needed for terminal spacing
+func EncodePNGToSixel(pngData []byte, cellHeightPx int) (string, int, error) {
+	// Decode PNG
+	img, err := png.Decode(bytes.NewReader(pngData))
+	if err != nil {
+		return "", 0, err
+	}
+
+	// Encode to Sixel
+	var buf bytes.Buffer
+	enc := sixel.NewEncoder(&buf)
+	if err := enc.Encode(img); err != nil {
+		return "", 0, err
+	}
+
+	// Calculate rows: image height / cell height
+	bounds := img.Bounds()
+	rows := (bounds.Dy() + cellHeightPx - 1) / cellHeightPx
+
+	return buf.String(), rows, nil
+}

clib/sixelconv_test.go 🔗

@@ -0,0 +1,29 @@
+package clib
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+// 1x1 red PNG
+const testPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
+
+func TestEncodePNGToSixel(t *testing.T) {
+	pngData, err := base64.StdEncoding.DecodeString(testPNG)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sixel, rows, err := EncodePNGToSixel(pngData, 18)
+	if err != nil {
+		t.Fatalf("Failed to encode: %v", err)
+	}
+
+	if rows < 1 {
+		t.Errorf("Expected rows >= 1, got %d", rows)
+	}
+
+	if len(sixel) == 0 {
+		t.Error("Expected non-empty Sixel output")
+	}
+}

docs/docs/Features/Images.md 🔗

@@ -17,6 +17,16 @@ Full support is provided for terminals implementing the Kitty Graphics Protocol.
 - [Wayst](https://github.com/91861/wayst)
 - [Konsole](https://konsole.kde.org/)
 
+### 🖼️ Sixel
+
+Support for the Sixel graphics format, a bitmap protocol widely supported across terminal emulators and multiplexers.
+
+**Supported Terminals:**
+- [Foot](https://codeberg.org/dnkl/foot)
+- [mlterm](https://github.com/arakiken/mlterm)
+- [Zellij](https://zellij.dev/) (multiplexer — works on top of any terminal)
+- Any terminal with `TERM` containing `xterm` and `SIXEL=1` environment variable set
+
 ### 🖼️ iTerm2 Inline Images
 
 Native support is included for the iTerm2 inline image protocol.

go.mod 🔗

@@ -20,6 +20,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/hashicorp/golang-lru/v2 v2.0.7
 	github.com/knadh/go-pop3 v1.0.2
+	github.com/mattn/go-sixel v0.0.9
 	github.com/yuin/goldmark v1.8.2
 	github.com/yuin/gopher-lua v1.1.2
 	github.com/zalando/go-keyring v0.2.8
@@ -50,6 +51,7 @@ require (
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sahilm/fuzzy v0.1.1 // indirect
+	github.com/soniakeys/quant v1.0.0 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/net v0.52.0 // indirect
 	golang.org/x/oauth2 v0.4.0 // indirect

go.sum 🔗

@@ -85,6 +85,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW
 github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
 github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-sixel v0.0.9 h1:ncx/rVU35Ut7/6gpVk4deC4/Wp2js9fDKmFmWnzmGoY=
+github.com/mattn/go-sixel v0.0.9/go.mod h1:mfichvavqIDFW14LGU24ux/UZ/wF0/hG+4pUWOWrQgM=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -93,6 +95,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y=
+github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

view/html.go 🔗

@@ -235,14 +235,36 @@ func konsoleSupported() bool {
 	return false
 }
 
+func zellijSupported() bool {
+	return os.Getenv("ZELLIJ") != "" || os.Getenv("ZELLIJ_SESSION_NAME") != ""
+}
+
+func sixelSupported() bool {
+	// Zellij always supports Sixel
+	if zellijSupported() {
+		return true
+	}
+
+	// Native Sixel terminals
+	term := strings.ToLower(os.Getenv("TERM"))
+	return strings.Contains(term, "mlterm") ||
+		strings.Contains(term, "foot") ||
+		(strings.Contains(term, "xterm") && os.Getenv("SIXEL") == "1")
+}
+
 // ImageProtocolSupported checks if any supported image protocol terminal is detected.
 func ImageProtocolSupported() bool {
 	return imageProtocolSupported()
 }
 
+// SixelSupported returns true if the terminal uses the Sixel graphics protocol.
+func SixelSupported() bool {
+	return sixelSupported()
+}
+
 // imageProtocolSupported checks if any supported image protocol terminal is detected.
 func imageProtocolSupported() bool {
-	return kittySupported() || ghosttySupported() || iterm2Supported() ||
+	return sixelSupported() || kittySupported() || ghosttySupported() || iterm2Supported() ||
 		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
 }
 
@@ -422,12 +444,54 @@ func iterm2InlineImage(payload string) string {
 	return result
 }
 
+// sixelInlineImage returns Sixel escape sequence + newline placeholders
+func sixelInlineImage(base64PNG string) string {
+	data, err := base64.StdEncoding.DecodeString(base64PNG)
+	if err != nil {
+		return ""
+	}
+
+	cellHeight := getTerminalCellSize()
+	sixel, rows, err := clib.EncodePNGToSixel(data, cellHeight)
+	if err != nil {
+		debugImageProtocol("Sixel encoding failed: %v", err)
+		return ""
+	}
+
+	debugImageProtocol("Sixel: encoded %d bytes, %d rows", len(sixel), rows)
+
+	// Sixel sequences don't auto-advance cursor
+	// Add newlines to preserve layout
+	return sixel + strings.Repeat("\n", rows)
+}
+
+// sixelImageEscapeOnly returns raw Sixel for out-of-band rendering
+func sixelImageEscapeOnly(base64PNG string) string {
+	data, err := base64.StdEncoding.DecodeString(base64PNG)
+	if err != nil {
+		return ""
+	}
+
+	cellHeight := getTerminalCellSize()
+	sixel, _, err := clib.EncodePNGToSixel(data, cellHeight)
+	if err != nil {
+		return ""
+	}
+
+	return sixel
+}
+
 // renderInlineImage renders an image using the appropriate protocol for the detected terminal
 func renderInlineImage(payload string) string {
 	if payload == "" {
 		return ""
 	}
 
+	// Priority: Sixel in multiplexers overrides native protocols
+	if sixelSupported() {
+		return sixelInlineImage(payload)
+	}
+
 	if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
 		// These terminals use the Kitty graphics protocol
 		return kittyInlineImage(payload)
@@ -519,6 +583,27 @@ func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...
 		col = screenCol[0]
 	}
 
+	// Priority: Sixel in multiplexers
+	if sixelSupported() {
+		debugImageProtocol("Sixel: RenderImageToStdout row=%d col=%d base64len=%d", screenRow, col, len(placement.Base64))
+
+		// Encode once, reuse cached Sixel on subsequent renders (like Kitty's upload-once pattern)
+		if placement.SixelEncoded == "" {
+			placement.SixelEncoded = sixelImageEscapeOnly(placement.Base64)
+			if placement.SixelEncoded == "" {
+				debugImageProtocol("Sixel: sixelImageEscapeOnly returned empty")
+				return
+			}
+		}
+
+		debugImageProtocol("Sixel: rendering %d bytes at row=%d col=%d", len(placement.SixelEncoded), screenRow+1, col)
+		// Position cursor + render Sixel
+		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u",
+			screenRow+1, col, placement.SixelEncoded)
+		os.Stdout.Sync()
+		return
+	}
+
 	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
 	useIterm2 := iterm2Supported() || warpSupported()
 
@@ -564,11 +649,12 @@ type InlineImage struct {
 // line in the email body. Images are rendered directly to stdout (bypassing
 // bubbletea's cell-based renderer which cannot handle graphics protocols).
 type ImagePlacement struct {
-	Line     int    // Line number in the processed body text where the image starts
-	Base64   string // Base64-encoded image data (PNG)
-	Rows     int    // Number of terminal rows the image occupies
-	Uploaded bool   // Whether the image has been uploaded to the terminal via Kitty ID
-	ID       uint32 // Kitty image ID for display-by-reference
+	Line         int    // Line number in the processed body text where the image starts
+	Base64       string // Base64-encoded image data (PNG)
+	Rows         int    // Number of terminal rows the image occupies
+	Uploaded     bool   // Whether the image has been uploaded to the terminal via Kitty ID
+	ID           uint32 // Kitty image ID for display-by-reference
+	SixelEncoded string // Cached Sixel escape sequence (encode once, reuse on scroll)
 }
 
 // ProcessBodyWithInline renders the body and resolves CID inline images when provided.

view/html_test.go 🔗

@@ -173,6 +173,112 @@ func TestGhosttySupported(t *testing.T) {
 	}
 }
 
+func TestZellijDetection(t *testing.T) {
+	tests := []struct {
+		name     string
+		env      map[string]string
+		expected bool
+	}{
+		{"ZELLIJ set", map[string]string{"ZELLIJ": "1"}, true},
+		{"ZELLIJ_SESSION_NAME set", map[string]string{"ZELLIJ_SESSION_NAME": "test"}, true},
+		{"No Zellij", map[string]string{}, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Save and restore env
+			origZellij := os.Getenv("ZELLIJ")
+			origZellijSession := os.Getenv("ZELLIJ_SESSION_NAME")
+			defer func() {
+				if origZellij != "" {
+					os.Setenv("ZELLIJ", origZellij)
+				} else {
+					os.Unsetenv("ZELLIJ")
+				}
+				if origZellijSession != "" {
+					os.Setenv("ZELLIJ_SESSION_NAME", origZellijSession)
+				} else {
+					os.Unsetenv("ZELLIJ_SESSION_NAME")
+				}
+			}()
+
+			// Clear first
+			os.Unsetenv("ZELLIJ")
+			os.Unsetenv("ZELLIJ_SESSION_NAME")
+
+			// Set test env
+			for k, v := range tt.env {
+				os.Setenv(k, v)
+			}
+
+			if got := zellijSupported(); got != tt.expected {
+				t.Errorf("zellijSupported() = %v, want %v", got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestSixelDetection(t *testing.T) {
+	tests := []struct {
+		name     string
+		env      map[string]string
+		expected bool
+	}{
+		{"Zellij", map[string]string{"ZELLIJ": "1"}, true},
+		{"MLterm", map[string]string{"TERM": "mlterm"}, true},
+		{"foot", map[string]string{"TERM": "foot"}, true},
+		{"xterm with SIXEL", map[string]string{"TERM": "xterm", "SIXEL": "1"}, true},
+		{"plain xterm", map[string]string{"TERM": "xterm"}, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Save and restore env
+			origZellij := os.Getenv("ZELLIJ")
+			origZellijSession := os.Getenv("ZELLIJ_SESSION_NAME")
+			origTerm := os.Getenv("TERM")
+			origSixel := os.Getenv("SIXEL")
+			defer func() {
+				if origZellij != "" {
+					os.Setenv("ZELLIJ", origZellij)
+				} else {
+					os.Unsetenv("ZELLIJ")
+				}
+				if origZellijSession != "" {
+					os.Setenv("ZELLIJ_SESSION_NAME", origZellijSession)
+				} else {
+					os.Unsetenv("ZELLIJ_SESSION_NAME")
+				}
+				if origTerm != "" {
+					os.Setenv("TERM", origTerm)
+				} else {
+					os.Unsetenv("TERM")
+				}
+				if origSixel != "" {
+					os.Setenv("SIXEL", origSixel)
+				} else {
+					os.Unsetenv("SIXEL")
+				}
+			}()
+
+			// Clear all env first
+			os.Unsetenv("ZELLIJ")
+			os.Unsetenv("ZELLIJ_SESSION_NAME")
+			os.Unsetenv("TERM")
+			os.Unsetenv("SIXEL")
+
+			// Set test env
+			for k, v := range tt.env {
+				os.Setenv(k, v)
+			}
+
+			if got := sixelSupported(); got != tt.expected {
+				t.Errorf("sixelSupported() = %v, want %v", got, tt.expected)
+			}
+		})
+	}
+}
+
 func TestImageProtocolSupported(t *testing.T) {
 	// Save original environment variables
 	origTerm := os.Getenv("TERM")