From d4ffb554cd6aaded7fd280d5b3f6aa793cba58a1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 11:39:56 -0500 Subject: [PATCH] feat(ui): filepicker: support kitty graphics in tmux 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. --- 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(-) diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index a099cd2ff9fe6f707ac1e18c8470dba55794889c..fd2d11d7c4babb8bce99e5b77d5070f17a78ae8f 100644 --- a/internal/ui/dialog/filepicker.go +++ b/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 diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index e7f51239f8fd5ebecec1f5855911fbe459b340ac..e04a781c767c5677e0165cb3e191c60532dac0e6 100644 --- a/internal/ui/image/image.go +++ b/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")) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f11ad75454dd18f3a710f925da52309935290c37..49aa7ecaf3b0df49cc2291fb0229a5bd8d6095ff 100644 --- a/internal/ui/model/ui.go +++ b/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 {