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

Ayman Bagabas created

Change summary

internal/ui/dialog/filepicker.go |  4 +++-
internal/ui/image/image.go       | 33 ++++++++++++++++++++++++---------
internal/ui/model/ui.go          | 17 ++++++++++++-----
3 files changed, 39 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")
 	}
 }
 
@@ -184,7 +186,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 🔗

@@ -13,6 +13,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 +34,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 +58,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 +167,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
 	}
@@ -192,7 +198,6 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 		bounds := img.Bounds()
 		imgWidth := bounds.Dx()
 		imgHeight := bounds.Dy()
-
 		imgID := int(key.Hash())
 		if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
 			ID:               imgID,
@@ -205,9 +210,19 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 			Rows:             rows,
 			VirtualPlacement: true,
 			Quite:            1,
+			Chunk:            true,
+			ChunkFormatter: func(chunk string) string {
+				if tmux {
+					return ansi.TmuxPassthrough(chunk)
+				}
+				return chunk
+			},
 		}); err != nil {
 			slog.Error("failed to encode image for kitty graphics", "err", err)
-			return uiutil.ReportError(fmt.Errorf("failed to encode image"))
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  "failed to encode image",
+			}
 		}
 
 		return tea.RawMsg{Msg: buf.String()}

internal/ui/model/ui.go 🔗

@@ -1,6 +1,7 @@
 package model
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -288,11 +289,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())
@@ -341,6 +337,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 {
@@ -620,6 +622,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 {