feat(ui): filepicker: support kitty graphics in tmux

Ayman Bagabas created

This commit makes the file picker capable of previewing images when
running inside a tmux session. It detects the tmux environment and wraps
the Kitty graphics escape sequences appropriately for tmux.

It works by using the Kitty Graphics chunking feature and wrapping each
chunk in tmux passthrough sequences. This ensures that images are
rendered correctly in tmux panes.

Change summary

internal/ui/dialog/filepicker.go |  4 +
internal/ui/image/image.go       | 63 +++++++++++++++++++++++++++++----
internal/ui/model/ui.go          | 17 ++++++--
3 files changed, 69 insertions(+), 15 deletions(-)

Detailed changes

internal/ui/dialog/filepicker.go 🔗

@@ -34,6 +34,7 @@ type FilePicker struct {
 	fp              filepicker.Model
 	help            help.Model
 	previewingImage bool // indicates if an image is being previewed
+	isTmux          bool
 
 	km struct {
 		Select,
@@ -108,6 +109,7 @@ func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
 			f.imgEnc = fimage.EncodingKitty
 		}
 		f.cellSize = caps.CellSize()
+		_, f.isTmux = caps.Env.LookupEnv("TMUX")
 	}
 }
 
@@ -189,7 +191,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
 			img, err := loadImage(selFile)
 			if err == nil {
 				cmds = append(cmds, tea.Sequence(
-					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
+					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
 					func() tea.Msg {
 						f.previewingImage = true
 						return nil

internal/ui/image/image.go 🔗

@@ -2,6 +2,7 @@ package image
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"hash/fnv"
 	"image"
@@ -13,6 +14,7 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/ansi/kitty"
 	"github.com/charmbracelet/x/mosaic"
@@ -33,6 +35,8 @@ type Capabilities struct {
 	// SupportsKittyGraphics indicates whether the terminal supports the Kitty
 	// graphics protocol.
 	SupportsKittyGraphics bool
+	// Env is the terminal environment variables.
+	Env uv.Environ
 }
 
 // CellSize returns the size of a single terminal cell in pixels.
@@ -55,12 +59,15 @@ func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellS
 
 // RequestCapabilities is a [tea.Cmd] that requests the terminal to report
 // its image related capabilities to the program.
-func RequestCapabilities() tea.Cmd {
-	return tea.Raw(
-		ansi.WindowOp(14) + // Window size in pixels
-			// ID 31 is just a random ID used to detect Kitty graphics support.
-			ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
-	)
+func RequestCapabilities(env uv.Environ) tea.Cmd {
+	winOpReq := ansi.WindowOp(14) // Window size in pixels
+	// ID 31 is just a random ID used to detect Kitty graphics support.
+	kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
+	if _, isTmux := env.LookupEnv("TMUX"); isTmux {
+		kittyReq = ansi.TmuxPassthrough(kittyReq)
+	}
+
+	return tea.Raw(winOpReq + kittyReq)
 }
 
 // TransmittedMsg is a message indicating that an image has been transmitted to
@@ -161,7 +168,7 @@ func HasTransmitted(id string, cols, rows int) bool {
 
 // Transmit transmits the image data to the terminal if needed. This is used to
 // cache the image on the terminal for later rendering.
-func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
 	if img == nil {
 		return nil
 	}
@@ -188,13 +195,50 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 		}
 
 		var buf bytes.Buffer
+		rp, wp := io.Pipe()
+		go func() {
+			for {
+				// Read single Kitty graphic chunks from the pipe and wrap them
+				// for tmux if needed.
+				var out bytes.Buffer
+				seenEsc := false
+				for {
+					var p [1]byte
+					n, err := rp.Read(p[:])
+					if n > 0 {
+						out.WriteByte(p[0])
+						if p[0] == ansi.ESC {
+							seenEsc = true
+						} else if seenEsc && p[0] == '\\' {
+							// End of Kitty graphics sequence
+							break
+						} else {
+							seenEsc = false
+						}
+					}
+					if err != nil {
+						if !errors.Is(err, io.EOF) {
+							slog.Error("error reading from pipe", "err", err)
+						}
+						return
+					}
+				}
+
+				seq := out.String()
+				if tmux {
+					seq = ansi.TmuxPassthrough(seq)
+				}
+
+				buf.WriteString(seq)
+			}
+		}()
+
 		img := fitImage(id, img, cs, cols, rows)
 		bounds := img.Bounds()
 		imgWidth := bounds.Dx()
 		imgHeight := bounds.Dy()
-
 		imgID := int(key.Hash())
-		if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+		if err := kitty.EncodeGraphics(wp, img, &kitty.Options{
 			ID:               imgID,
 			Action:           kitty.TransmitAndPut,
 			Transmission:     kitty.Direct,
@@ -205,6 +249,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 			Rows:             rows,
 			VirtualPlacement: true,
 			Quite:            1,
+			Chunk:            true,
 		}); err != nil {
 			slog.Error("failed to encode image for kitty graphics", "err", err)
 			return uiutil.ReportError(fmt.Errorf("failed to encode image"))

internal/ui/model/ui.go 🔗

@@ -1,6 +1,7 @@
 package model
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -270,11 +271,6 @@ func (m *UI) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	if m.QueryVersion {
 		cmds = append(cmds, tea.RequestTerminalVersion)
-		// XXX: Right now, we're using the same logic to determine image
-		// support. Terminals like Apple Terminal and possibly others might
-		// bleed characters when querying for Kitty graphics via APC escape
-		// sequences.
-		cmds = append(cmds, timage.RequestCapabilities())
 	}
 	// load the user commands async
 	cmds = append(cmds, m.loadCustomCommands())
@@ -316,6 +312,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if !m.sendProgressBar {
 			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 		}
+		m.imgCaps.Env = uv.Environ(msg)
+		// XXX: Right now, we're using the same logic to determine image
+		// support. Terminals like Apple Terminal and possibly others might
+		// bleed characters when querying for Kitty graphics via APC escape
+		// sequences.
+		cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
 	case loadSessionMsg:
 		m.state = uiChat
 		if m.forceCompactMode {
@@ -556,6 +558,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// captures the response. Any response means the terminal understands
 		// the protocol.
 		m.imgCaps.SupportsKittyGraphics = true
+		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
+			slog.Warn("unexpected Kitty graphics response",
+				"response", string(msg.Payload),
+				"options", msg.Options)
+		}
 	default:
 		if m.dialog.HasDialogs() {
 			if cmd := m.handleDialogMsg(msg); cmd != nil {