on main help sign in
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.

commitd4ffb554cd6aaded7fd280d5b3f6aa793cba58a1 authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 15 Jan 2026, 11:39 (9 months ago) parent1f0605a
3 files changed+69−15
internal/ui/dialog/filepicker.go+3−1 internal/ui/image/image.go+54−9 internal/ui/model/ui.go+12−5
internal/ui/dialog/filepicker.go+3−1
@@ -34,6 +34,7 @@ type FilePicker struct {
3434 fp filepicker.Model
3535 help help.Model
3636 previewingImage bool // indicates if an image is being previewed
37+ isTmux bool
3738
3839 km struct {
3940 Select,
@@ -108,6 +109,7 @@ func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
108109 f.imgEnc = fimage.EncodingKitty
109110 }
110111 f.cellSize = caps.CellSize()
112+ _, f.isTmux = caps.Env.LookupEnv("TMUX")
111113 }
112114}
113115
@@ -189,7 +191,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
189191 img, err := loadImage(selFile)
190192 if err == nil {
191193 cmds = append(cmds, tea.Sequence(
192− f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
194+ f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
193195 func() tea.Msg {
194196 f.previewingImage = true
195197 return nil
internal/ui/image/image.go+54−9
@@ -2,6 +2,7 @@ package image
22
33import (
44 "bytes"
5+ "errors"
56 "fmt"
67 "hash/fnv"
78 "image"
@@ -13,6 +14,7 @@ import (
1314
1415 tea "charm.land/bubbletea/v2"
1516 "github.com/charmbracelet/crush/internal/uiutil"
17+ uv "github.com/charmbracelet/ultraviolet"
1618 "github.com/charmbracelet/x/ansi"
1719 "github.com/charmbracelet/x/ansi/kitty"
1820 "github.com/charmbracelet/x/mosaic"
@@ -33,6 +35,8 @@ type Capabilities struct {
3335 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
3436 // graphics protocol.
3537 SupportsKittyGraphics bool
38+ // Env is the terminal environment variables.
39+ Env uv.Environ
3640}
3741
3842// CellSize returns the size of a single terminal cell in pixels.
@@ -55,12 +59,15 @@ func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellS
5559
5660// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
5761// its image related capabilities to the program.
58−func RequestCapabilities() tea.Cmd {
59− return tea.Raw(
60− ansi.WindowOp(14) + // Window size in pixels
61− // ID 31 is just a random ID used to detect Kitty graphics support.
62− ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
63− )
62+func RequestCapabilities(env uv.Environ) tea.Cmd {
63+ winOpReq := ansi.WindowOp(14) // Window size in pixels
64+ // ID 31 is just a random ID used to detect Kitty graphics support.
65+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
66+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
67+ kittyReq = ansi.TmuxPassthrough(kittyReq)
68+ }
69+
70+ return tea.Raw(winOpReq + kittyReq)
6471}
6572
6673// TransmittedMsg is a message indicating that an image has been transmitted to
@@ -161,7 +168,7 @@ func HasTransmitted(id string, cols, rows int) bool {
161168
162169// Transmit transmits the image data to the terminal if needed. This is used to
163170// cache the image on the terminal for later rendering.
164−func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
171+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
165172 if img == nil {
166173 return nil
167174 }
@@ -188,13 +195,50 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
188195 }
189196
190197 var buf bytes.Buffer
198+ rp, wp := io.Pipe()
199+ go func() {
200+ for {
201+ // Read single Kitty graphic chunks from the pipe and wrap them
202+ // for tmux if needed.
203+ var out bytes.Buffer
204+ seenEsc := false
205+ for {
206+ var p [1]byte
207+ n, err := rp.Read(p[:])
208+ if n > 0 {
209+ out.WriteByte(p[0])
210+ if p[0] == ansi.ESC {
211+ seenEsc = true
212+ } else if seenEsc && p[0] == '\\' {
213+ // End of Kitty graphics sequence
214+ break
215+ } else {
216+ seenEsc = false
217+ }
218+ }
219+ if err != nil {
220+ if !errors.Is(err, io.EOF) {
221+ slog.Error("error reading from pipe", "err", err)
222+ }
223+ return
224+ }
225+ }
226+
227+ seq := out.String()
228+ if tmux {
229+ seq = ansi.TmuxPassthrough(seq)
230+ }
231+
232+ buf.WriteString(seq)
233+ }
234+ }()
235+
191236 img := fitImage(id, img, cs, cols, rows)
192237 bounds := img.Bounds()
193238 imgWidth := bounds.Dx()
194239 imgHeight := bounds.Dy()
195−
196240 imgID := int(key.Hash())
197− if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
241+ if err := kitty.EncodeGraphics(wp, img, &kitty.Options{
198242 ID: imgID,
199243 Action: kitty.TransmitAndPut,
200244 Transmission: kitty.Direct,
@@ -205,6 +249,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
205249 Rows: rows,
206250 VirtualPlacement: true,
207251 Quite: 1,
252+ Chunk: true,
208253 }); err != nil {
209254 slog.Error("failed to encode image for kitty graphics", "err", err)
210255 return uiutil.ReportError(fmt.Errorf("failed to encode image"))
internal/ui/model/ui.go+12−5
@@ -1,6 +1,7 @@
11package model
22
33import (
4+ "bytes"
45 "context"
56 "errors"
67 "fmt"
@@ -270,11 +271,6 @@ func (m *UI) Init() tea.Cmd {
270271 var cmds []tea.Cmd
271272 if m.QueryVersion {
272273 cmds = append(cmds, tea.RequestTerminalVersion)
273− // XXX: Right now, we're using the same logic to determine image
274− // support. Terminals like Apple Terminal and possibly others might
275− // bleed characters when querying for Kitty graphics via APC escape
276− // sequences.
277− cmds = append(cmds, timage.RequestCapabilities())
278274 }
279275 // load the user commands async
280276 cmds = append(cmds, m.loadCustomCommands())
@@ -316,6 +312,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
316312 if !m.sendProgressBar {
317313 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
318314 }
315+ m.imgCaps.Env = uv.Environ(msg)
316+ // XXX: Right now, we're using the same logic to determine image
317+ // support. Terminals like Apple Terminal and possibly others might
318+ // bleed characters when querying for Kitty graphics via APC escape
319+ // sequences.
320+ cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
319321 case loadSessionMsg:
320322 m.state = uiChat
321323 if m.forceCompactMode {
@@ -556,6 +558,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
556558 // captures the response. Any response means the terminal understands
557559 // the protocol.
558560 m.imgCaps.SupportsKittyGraphics = true
561+ if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
562+ slog.Warn("unexpected Kitty graphics response",
563+ "response", string(msg.Payload),
564+ "options", msg.Options)
565+ }
559566 default:
560567 if m.dialog.HasDialogs() {
561568 if cmd := m.handleDialogMsg(msg); cmd != nil {