on main help sign in
feat(ui): filepicker: support kitty graphics in tmux (#1884)
8ded0beAyman 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
@@ -184,7 +186,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
184 img, err := loadImage(selFile)
186 img, err := loadImage(selFile)
185 if err == nil {
187 if err == nil {
186 cmds = append(cmds, tea.Sequence(
188 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),
188 func() tea.Msg {
190 func() tea.Msg {
189 f.previewingImage = true
191 f.previewingImage = true
190 return nil
192 return nil
internal/ui/image/image.go+24−9
@@ -13,6 +13,7 @@ import (
13
13
14 tea "charm.land/bubbletea/v2"
14 tea "charm.land/bubbletea/v2"
15 "github.com/charmbracelet/crush/internal/uiutil"
15 "github.com/charmbracelet/crush/internal/uiutil"
16+ uv "github.com/charmbracelet/ultraviolet"
16 "github.com/charmbracelet/x/ansi"
17 "github.com/charmbracelet/x/ansi"
17 "github.com/charmbracelet/x/ansi/kitty"
18 "github.com/charmbracelet/x/ansi/kitty"
18 "github.com/charmbracelet/x/mosaic"
19 "github.com/charmbracelet/x/mosaic"
@@ -33,6 +34,8 @@ type Capabilities struct {
33 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
34 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
34 // graphics protocol.
35 // graphics protocol.
35 SupportsKittyGraphics bool
36 SupportsKittyGraphics bool
37+ // Env is the terminal environment variables.
38+ Env uv.Environ
36}
39}
37
40
38// CellSize returns the size of a single terminal cell in pixels.
41// CellSize returns the size of a single terminal cell in pixels.
@@ -55,12 +58,15 @@ func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellS
55
58
56// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
59// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
57// its image related capabilities to the program.
60// its image related capabilities to the program.
58−func RequestCapabilities() tea.Cmd {
61+func RequestCapabilities(env uv.Environ) tea.Cmd {
59− return tea.Raw(
60− ansi.WindowOp(14) + // Window size in pixels
62+ winOpReq := ansi.WindowOp(14) // Window size in pixels
61− // ID 31 is just a random ID used to detect Kitty graphics support.
63+ // 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"),
64+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
63− )
65+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
66+ kittyReq = ansi.TmuxPassthrough(kittyReq)
67+ }
68+
69+ return tea.Raw(winOpReq + kittyReq)
64}
70}
65
71
66// TransmittedMsg is a message indicating that an image has been transmitted to
72// TransmittedMsg is a message indicating that an image has been transmitted to
@@ -161,7 +167,7 @@ func HasTransmitted(id string, cols, rows int) bool {
161
167
162// Transmit transmits the image data to the terminal if needed. This is used to
168// Transmit transmits the image data to the terminal if needed. This is used to
163// cache the image on the terminal for later rendering.
169// 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 {
165 if img == nil {
171 if img == nil {
166 return nil
172 return nil
167 }
173 }
@@ -192,7 +198,6 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
192 bounds := img.Bounds()
198 bounds := img.Bounds()
193 imgWidth := bounds.Dx()
199 imgWidth := bounds.Dx()
194 imgHeight := bounds.Dy()
200 imgHeight := bounds.Dy()
195−
196 imgID := int(key.Hash())
201 imgID := int(key.Hash())
197 if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
202 if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
198 ID: imgID,
203 ID: imgID,
@@ -205,9 +210,19 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
205 Rows: rows,
210 Rows: rows,
206 VirtualPlacement: true,
211 VirtualPlacement: true,
207 Quite: 1,
212 Quite: 1,
213+ Chunk: true,
214+ ChunkFormatter: func(chunk string) string {
215+ if tmux {
216+ return ansi.TmuxPassthrough(chunk)
217+ }
218+ return chunk
219+ },
208 }); err != nil {
220 }); err != nil {
209 slog.Error("failed to encode image for kitty graphics", "err", err)
221 slog.Error("failed to encode image for kitty graphics", "err", err)
222+ return uiutil.InfoMsg{
223+ Type: uiutil.InfoTypeError,
210− return uiutil.ReportError(fmt.Errorf("failed to encode image"))
224+ Msg: "failed to encode image",
225+ }
211 }
226 }
212
227
213 return tea.RawMsg{Msg: buf.String()}
228 return tea.RawMsg{Msg: buf.String()}
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"
@@ -288,11 +289,6 @@ func (m *UI) Init() tea.Cmd {
288 var cmds []tea.Cmd
289 var cmds []tea.Cmd
289 if m.QueryVersion {
290 if m.QueryVersion {
290 cmds = append(cmds, tea.RequestTerminalVersion)
291 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())
296 }
292 }
297 // load the user commands async
293 // load the user commands async
298 cmds = append(cmds, m.loadCustomCommands())
294 cmds = append(cmds, m.loadCustomCommands())
@@ -341,6 +337,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
341 if !m.sendProgressBar {
337 if !m.sendProgressBar {
342 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
338 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
343 }
339 }
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))
344 case loadSessionMsg:
346 case loadSessionMsg:
345 m.state = uiChat
347 m.state = uiChat
346 if m.forceCompactMode {
348 if m.forceCompactMode {
@@ -620,6 +622,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
620 // captures the response. Any response means the terminal understands
622 // captures the response. Any response means the terminal understands
621 // the protocol.
623 // the protocol.
622 m.imgCaps.SupportsKittyGraphics = true
624 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+ }
623 default:
630 default:
624 if m.dialog.HasDialogs() {
631 if m.dialog.HasDialogs() {
625 if cmd := m.handleDialogMsg(msg); cmd != nil {
632 if cmd := m.handleDialogMsg(msg); cmd != nil {