on main help sign in
feat(ui): filepicker: support kitty graphics in tmux
d4ffb55Ayman Bagabas
internal/ui/dialog/filepicker.go+3−1
@@ -34,6 +34,7 @@ type FilePicker struct {
34 fp filepicker.Model
34 fp filepicker.Model
35 help help.Model
35 help help.Model
36 previewingImage bool // indicates if an image is being previewed
36 previewingImage bool // indicates if an image is being previewed
37+ isTmux bool
37
38
38 km struct {
39 km struct {
39 Select,
40 Select,
@@ -108,6 +109,7 @@ func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
108 f.imgEnc = fimage.EncodingKitty
109 f.imgEnc = fimage.EncodingKitty
109 }
110 }
110 f.cellSize = caps.CellSize()
111 f.cellSize = caps.CellSize()
112+ _, f.isTmux = caps.Env.LookupEnv("TMUX")
111 }
113 }
112}
114}
113
115
@@ -189,7 +191,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
189 img, err := loadImage(selFile)
191 img, err := loadImage(selFile)
190 if err == nil {
192 if err == nil {
191 cmds = append(cmds, tea.Sequence(
193 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),
193 func() tea.Msg {
195 func() tea.Msg {
194 f.previewingImage = true
196 f.previewingImage = true
195 return nil
197 return nil
internal/ui/image/image.go+54−9
@@ -2,6 +2,7 @@ package image
2
2
3import (
3import (
4 "bytes"
4 "bytes"
5+ "errors"
5 "fmt"
6 "fmt"
6 "hash/fnv"
7 "hash/fnv"
7 "image"
8 "image"
@@ -13,6 +14,7 @@ import (
13
14
14 tea "charm.land/bubbletea/v2"
15 tea "charm.land/bubbletea/v2"
15 "github.com/charmbracelet/crush/internal/uiutil"
16 "github.com/charmbracelet/crush/internal/uiutil"
17+ uv "github.com/charmbracelet/ultraviolet"
16 "github.com/charmbracelet/x/ansi"
18 "github.com/charmbracelet/x/ansi"
17 "github.com/charmbracelet/x/ansi/kitty"
19 "github.com/charmbracelet/x/ansi/kitty"
18 "github.com/charmbracelet/x/mosaic"
20 "github.com/charmbracelet/x/mosaic"
@@ -33,6 +35,8 @@ type Capabilities struct {
33 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
35 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
34 // graphics protocol.
36 // graphics protocol.
35 SupportsKittyGraphics bool
37 SupportsKittyGraphics bool
38+ // Env is the terminal environment variables.
39+ Env uv.Environ
36}
40}
37
41
38// CellSize returns the size of a single terminal cell in pixels.
42// CellSize returns the size of a single terminal cell in pixels.
@@ -55,12 +59,15 @@ func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellS
55
59
56// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
60// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
57// its image related capabilities to the program.
61// its image related capabilities to the program.
58−func RequestCapabilities() tea.Cmd {
62+func RequestCapabilities(env uv.Environ) tea.Cmd {
59− return tea.Raw(
60− ansi.WindowOp(14) + // Window size in pixels
63+ winOpReq := ansi.WindowOp(14) // Window size in pixels
61− // ID 31 is just a random ID used to detect Kitty graphics support.
64+ // 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"),
65+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
63− )
66+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
67+ kittyReq = ansi.TmuxPassthrough(kittyReq)
68+ }
69+
70+ return tea.Raw(winOpReq + kittyReq)
64}
71}
65
72
66// TransmittedMsg is a message indicating that an image has been transmitted to
73// TransmittedMsg is a message indicating that an image has been transmitted to
@@ -161,7 +168,7 @@ func HasTransmitted(id string, cols, rows int) bool {
161
168
162// Transmit transmits the image data to the terminal if needed. This is used to
169// Transmit transmits the image data to the terminal if needed. This is used to
163// cache the image on the terminal for later rendering.
170// 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 {
165 if img == nil {
172 if img == nil {
166 return nil
173 return nil
167 }
174 }
@@ -188,13 +195,50 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
188 }
195 }
189
196
190 var buf bytes.Buffer
197 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+
191 img := fitImage(id, img, cs, cols, rows)
236 img := fitImage(id, img, cs, cols, rows)
192 bounds := img.Bounds()
237 bounds := img.Bounds()
193 imgWidth := bounds.Dx()
238 imgWidth := bounds.Dx()
194 imgHeight := bounds.Dy()
239 imgHeight := bounds.Dy()
195−
196 imgID := int(key.Hash())
240 imgID := int(key.Hash())
197− if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
241+ if err := kitty.EncodeGraphics(wp, img, &kitty.Options{
198 ID: imgID,
242 ID: imgID,
199 Action: kitty.TransmitAndPut,
243 Action: kitty.TransmitAndPut,
200 Transmission: kitty.Direct,
244 Transmission: kitty.Direct,
@@ -205,6 +249,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
205 Rows: rows,
249 Rows: rows,
206 VirtualPlacement: true,
250 VirtualPlacement: true,
207 Quite: 1,
251 Quite: 1,
252+ Chunk: true,
208 }); err != nil {
253 }); err != nil {
209 slog.Error("failed to encode image for kitty graphics", "err", err)
254 slog.Error("failed to encode image for kitty graphics", "err", err)
210 return uiutil.ReportError(fmt.Errorf("failed to encode image"))
255 return uiutil.ReportError(fmt.Errorf("failed to encode image"))
internal/ui/model/ui.go+12−5
@@ -1,6 +1,7 @@
1package model
1package model
2
2
3import (
3import (
4+ "bytes"
4 "context"
5 "context"
5 "errors"
6 "errors"
6 "fmt"
7 "fmt"
@@ -270,11 +271,6 @@ func (m *UI) Init() tea.Cmd {
270 var cmds []tea.Cmd
271 var cmds []tea.Cmd
271 if m.QueryVersion {
272 if m.QueryVersion {
272 cmds = append(cmds, tea.RequestTerminalVersion)
273 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())
278 }
274 }
279 // load the user commands async
275 // load the user commands async
280 cmds = append(cmds, m.loadCustomCommands())
276 cmds = append(cmds, m.loadCustomCommands())
@@ -316,6 +312,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
316 if !m.sendProgressBar {
312 if !m.sendProgressBar {
317 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
313 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
318 }
314 }
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))
319 case loadSessionMsg:
321 case loadSessionMsg:
320 m.state = uiChat
322 m.state = uiChat
321 if m.forceCompactMode {
323 if m.forceCompactMode {
@@ -556,6 +558,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
556 // captures the response. Any response means the terminal understands
558 // captures the response. Any response means the terminal understands
557 // the protocol.
559 // the protocol.
558 m.imgCaps.SupportsKittyGraphics = true
560 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+ }
559 default:
566 default:
560 if m.dialog.HasDialogs() {
567 if m.dialog.HasDialogs() {
561 if cmd := m.handleDialogMsg(msg); cmd != nil {
568 if cmd := m.handleDialogMsg(msg); cmd != nil {