on main help sign in
feat(ui): filepicker: support kitty graphics in tmux (#1884)
commit8ded0befafd9b876220159a753de464a19f6982d authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 20 Jan 2026, 09:43 (9 months ago) parentc3e9196ded1e6b
3 files changed+39−15
internal/ui/dialog/filepicker.go+3−1 internal/ui/image/image.go+24−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
@@ -184,7 +186,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
184186 img, err := loadImage(selFile)
185187 if err == nil {
186188 cmds = append(cmds, tea.Sequence(
187− f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
189+ f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
188190 func() tea.Msg {
189191 f.previewingImage = true
190192 return nil
internal/ui/image/image.go+24−9
@@ -13,6 +13,7 @@ import (
1313
1414 tea "charm.land/bubbletea/v2"
1515 "github.com/charmbracelet/crush/internal/uiutil"
16+ uv "github.com/charmbracelet/ultraviolet"
1617 "github.com/charmbracelet/x/ansi"
1718 "github.com/charmbracelet/x/ansi/kitty"
1819 "github.com/charmbracelet/x/mosaic"
@@ -33,6 +34,8 @@ type Capabilities struct {
3334 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
3435 // graphics protocol.
3536 SupportsKittyGraphics bool
37+ // Env is the terminal environment variables.
38+ Env uv.Environ
3639}
3740
3841// CellSize returns the size of a single terminal cell in pixels.
@@ -55,12 +58,15 @@ func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellS
5558
5659// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
5760// 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− )
61+func RequestCapabilities(env uv.Environ) tea.Cmd {
62+ winOpReq := ansi.WindowOp(14) // Window size in pixels
63+ // ID 31 is just a random ID used to detect Kitty graphics support.
64+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
65+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
66+ kittyReq = ansi.TmuxPassthrough(kittyReq)
67+ }
68+
69+ return tea.Raw(winOpReq + kittyReq)
6470}
6571
6672// TransmittedMsg is a message indicating that an image has been transmitted to
@@ -161,7 +167,7 @@ func HasTransmitted(id string, cols, rows int) bool {
161167
162168// Transmit transmits the image data to the terminal if needed. This is used to
163169// 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 {
170+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
165171 if img == nil {
166172 return nil
167173 }
@@ -192,7 +198,6 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
192198 bounds := img.Bounds()
193199 imgWidth := bounds.Dx()
194200 imgHeight := bounds.Dy()
195−
196201 imgID := int(key.Hash())
197202 if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
198203 ID: imgID,
@@ -205,9 +210,19 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
205210 Rows: rows,
206211 VirtualPlacement: true,
207212 Quite: 1,
213+ Chunk: true,
214+ ChunkFormatter: func(chunk string) string {
215+ if tmux {
216+ return ansi.TmuxPassthrough(chunk)
217+ }
218+ return chunk
219+ },
208220 }); err != nil {
209221 slog.Error("failed to encode image for kitty graphics", "err", err)
210− return uiutil.ReportError(fmt.Errorf("failed to encode image"))
222+ return uiutil.InfoMsg{
223+ Type: uiutil.InfoTypeError,
224+ Msg: "failed to encode image",
225+ }
211226 }
212227
213228 return tea.RawMsg{Msg: buf.String()}
internal/ui/model/ui.go+12−5
@@ -1,6 +1,7 @@
11package model
22
33import (
4+ "bytes"
45 "context"
56 "errors"
67 "fmt"
@@ -288,11 +289,6 @@ func (m *UI) Init() tea.Cmd {
288289 var cmds []tea.Cmd
289290 if m.QueryVersion {
290291 cmds = append(cmds, tea.RequestTerminalVersion)
291− // XXX: Right now, we're using the same logic to determine image
292− // support. Terminals like Apple Terminal and possibly others might
293− // bleed characters when querying for Kitty graphics via APC escape
294− // sequences.
295− cmds = append(cmds, timage.RequestCapabilities())
296292 }
297293 // load the user commands async
298294 cmds = append(cmds, m.loadCustomCommands())
@@ -341,6 +337,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
341337 if !m.sendProgressBar {
342338 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
343339 }
340+ m.imgCaps.Env = uv.Environ(msg)
341+ // XXX: Right now, we're using the same logic to determine image
342+ // support. Terminals like Apple Terminal and possibly others might
343+ // bleed characters when querying for Kitty graphics via APC escape
344+ // sequences.
345+ cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
344346 case loadSessionMsg:
345347 m.state = uiChat
346348 if m.forceCompactMode {
@@ -620,6 +622,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
620622 // captures the response. Any response means the terminal understands
621623 // the protocol.
622624 m.imgCaps.SupportsKittyGraphics = true
625+ if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
626+ slog.Warn("unexpected Kitty graphics response",
627+ "response", string(msg.Payload),
628+ "options", msg.Options)
629+ }
623630 default:
624631 if m.dialog.HasDialogs() {
625632 if cmd := m.handleDialogMsg(msg); cmd != nil {