Detailed changes
@@ -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
+}
@@ -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")
+ }
+}
@@ -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.
@@ -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
@@ -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=
@@ -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.
@@ -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")