From b5f2ba4fd4f1ef06d8ba7283ec02d9b06c301ad9 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Tue, 28 Jul 2026 10:45:34 +0900 Subject: [PATCH] Wrap bare Kitty graphics sequences in tmux passthrough A Kitty graphics command is an APC sequence, which tmux takes as a request to set the pane title. A bare one therefore never reaches the terminal and overwrites the title on the way, which is visible on the border of a floating pane. Programs are expected to wrap it in the tmux passthrough sequence themselves, but 'kitten icat --clear' does not, unlike the image data it emits right after. Sixel and iTerm2 sequences are left alone; tmux renders the former itself and neither affects the title. Related: #4870 --- src/terminal.go | 20 +++++++++++++++++++- src/terminal_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/terminal.go b/src/terminal.go index 5211a1fa..c84970c4 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -59,6 +59,8 @@ var passThroughBeginRegex *regexp.Regexp var passThroughEndTmuxRegex *regexp.Regexp var ttyin *os.File +var inTmux = len(os.Getenv("TMUX")) > 0 + const clearCode string = "\x1b[2J" // Number of maximum focus events to process synchronously @@ -4756,6 +4758,22 @@ func findPassThrough(line string) []int { return []int{loc[0], loc[1] + pos + 2} } +// tmux takes a bare APC as a request to set the pane title, so a Kitty +// graphics command never reaches the terminal and clobbers the title on the +// way. 'kitten icat --clear' emits one unwrapped. Sixel is left alone. +// https://github.com/junegunn/fzf/issues/4870 +func wrapPassThrough(passThrough string, tmux bool) string { + if !tmux || !strings.HasPrefix(passThrough, "\x1b_G") { + return passThrough + } + // Only the sequence is passed through, not the trailing CR + suffix := "" + if strings.HasSuffix(passThrough, "\r") { + passThrough, suffix = passThrough[:len(passThrough)-1], "\r" + } + return "\x1bPtmux;" + strings.ReplaceAll(passThrough, "\x1b", "\x1b\x1b") + "\x1b\\" + suffix +} + func extractPassThroughs(line string) ([]string, string) { passThroughs := []string{} transformed := "" @@ -4996,7 +5014,7 @@ Loop: } else { t.pwindow.Move(y, x) } - t.tui.PassThrough(passThrough) + t.tui.PassThrough(wrapPassThrough(passThrough, inTmux)) if requiredLines > 0 { if y+requiredLines == height { diff --git a/src/terminal_test.go b/src/terminal_test.go index b26ad52a..8a22f054 100644 --- a/src/terminal_test.go +++ b/src/terminal_test.go @@ -549,6 +549,35 @@ func TestExtractPassthroughs(t *testing.T) { } } +func TestWrapPassThrough(t *testing.T) { + for _, passThrough := range []string{ + "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", // Already wrapped + "\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", // Sixel + "\x1b]1337;File=inline=1:AAAA\a", // iTerm2 + } { + if got := wrapPassThrough(passThrough, true); got != passThrough { + t.Errorf("should have been left alone: %q -> %q", passThrough, got) + } + } + + kitty := "\x1b_Ga=d,d=A\x1b\\" + if got := wrapPassThrough(kitty, false); got != kitty { + t.Errorf("should have been left alone outside of tmux: %q", got) + } + + // ESC characters are doubled, and the trailing carriage return is kept + // outside of the wrapper + for _, test := range []struct{ input, want string }{ + {kitty, "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\"}, + {kitty + "\r", "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\\r"}, + {"\x1b_Gm=1;\x1bAAA=\x1b\\", "\x1bPtmux;\x1b\x1b_Gm=1;\x1b\x1bAAA=\x1b\x1b\\\x1b\\"}, + } { + if got := wrapPassThrough(test.input, true); got != test.want { + t.Errorf("expected %q, got %q", test.want, got) + } + } +} + /* utilities section */ // Item represents one line in fzf UI. Usually it is relative path to files and folders.