From d4ffb554cd6aaded7fd280d5b3f6aa793cba58a1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 11:39:56 -0500 Subject: [PATCH 1/2] 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 { From ded1e6b277bbfac8168dc1f978de3da5f3bc0138 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 12:00:37 -0500 Subject: [PATCH 2/2] fix(ui): filepicker: simplify tmux kitty image encoding --- go.mod | 4 +-- go.sum | 8 +++--- internal/ui/image/image.go | 52 ++++++++------------------------------ 3 files changed, 17 insertions(+), 47 deletions(-) diff --git a/go.mod b/go.mod index 0b253c7052b9813f10b721d763ade79aba356624..6a856d069637b09e324a090dd0c3a5cac4d62ff4 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 - github.com/charmbracelet/x/ansi v0.11.3 + github.com/charmbracelet/x/ansi v0.11.4 github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/etag v0.2.0 github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f @@ -104,7 +104,7 @@ require ( github.com/charmbracelet/x/json v0.2.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.6.2 // indirect + github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 6346b736a83815afe4daff8390a03b941539ad6d..3a9f73bdd233aea6d5f449a8e0e27234bb519c9d 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1Yk github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2hypGoPKBy3ooKzW0TFxaxhyHK3NbkLLn4KeRFc= github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc= -github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= -github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= +github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk= github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= @@ -130,8 +130,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= -github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= +github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index e04a781c767c5677e0165cb3e191c60532dac0e6..06183ae8142b6d7f2e4ff932cdfa07273f1a16c8 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -2,7 +2,6 @@ package image import ( "bytes" - "errors" "fmt" "hash/fnv" "image" @@ -195,50 +194,12 @@ 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(wp, img, &kitty.Options{ + if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{ ID: imgID, Action: kitty.TransmitAndPut, Transmission: kitty.Direct, @@ -250,9 +211,18 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i 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()}