From 8cf40cb37297669aaf77b3424eee8d32111cacae Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Wed, 29 Jul 2026 16:51:33 +0900 Subject: [PATCH] Do not split preview header through an image --preview-window ~N draws the header in its own pass, resuming the body in the middle of an image: an image arrives as one line but takes up many rows. ~N is typically global and meant for text, while the same preview command also renders images. --- src/terminal.go | 66 ++++++++++++++++++++++++++++++++++++++++---- src/terminal_test.go | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/terminal.go b/src/terminal.go index ea748f16..e0054236 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -57,6 +57,7 @@ var offsetComponentRegex *regexp.Regexp var offsetTrimCharsRegex *regexp.Regexp var passThroughBeginRegex *regexp.Regexp var passThroughEndTmuxRegex *regexp.Regexp +var sixelBeginRegex *regexp.Regexp var ttyin *os.File var inTmux = len(os.Getenv("TMUX")) > 0 @@ -94,6 +95,7 @@ func init() { */ passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`) passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`) + sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`) } type jumpMode int @@ -4677,15 +4679,23 @@ func (t *Terminal) renderPreviewArea(unchanged bool) { height := t.pwindow.Height() body := t.previewer.lines headerLines := t.activePreviewOpts.headerLines + lineNo := -t.previewer.offset + headerLines + // Scrollbar is sized from the body alone, split off or not + scrollLines := len(body) // Do not enable preview header lines if it's value is too large if headerLines > 0 && headerLines < min(len(body), height) { + scrollLines -= headerLines header := t.previewer.lines[0:headerLines] - body = t.previewer.lines[headerLines:] - // Always redraw header - t.renderPreviewText(height, header, 0, false) - t.pwindow.MoveAndClear(t.pwindow.Y(), 0) + // A separate header pass would resume the body inside an image, which + // takes up more rows than the line it arrives on + if !containsImage(header) { + // Always redraw header + t.renderPreviewText(height, header, 0, false) + t.pwindow.MoveAndClear(t.pwindow.Y(), 0) + body = t.previewer.lines[headerLines:] + } } - t.renderPreviewText(height, body, -t.previewer.offset+headerLines, unchanged) + t.renderPreviewText(height, body, lineNo, unchanged) if !unchanged { t.pwindow.FinishFill() @@ -4696,7 +4706,7 @@ func (t *Terminal) renderPreviewArea(unchanged bool) { } effectiveHeight := height - headerLines - barLength, barStart := getScrollbar(1, len(body), effectiveHeight, min(len(body)-effectiveHeight, t.previewer.offset-headerLines)) + barLength, barStart := getScrollbar(1, scrollLines, effectiveHeight, min(scrollLines-effectiveHeight, t.previewer.offset-headerLines)) t.renderPreviewScrollbar(headerLines, barLength, barStart) } @@ -4776,6 +4786,50 @@ func wrapPassThrough(passThrough string, tmux bool) string { return "\x1bPtmux;" + strings.ReplaceAll(passThrough, "\x1b", "\x1b\x1b") + "\x1b\\" + suffix } +// Whether the sequence draws an image. Kitty commands that only transmit or +// delete do not +func isImagePassThrough(passThrough string) bool { + // Unwrap the tmux passthrough sequence, in which every ESC is doubled + if after, ok := strings.CutPrefix(passThrough, "\x1bPtmux;"); ok { + passThrough = strings.ReplaceAll(after, "\x1b\x1b", "\x1b") + } + if after, ok := strings.CutPrefix(passThrough, "\x1b_G"); ok { + // Control data ends at the payload delimiter or at the terminator + keys := after + if index := strings.IndexAny(keys, ";\x1b"); index >= 0 { + keys = keys[:index] + } + for _, key := range strings.Split(keys, ",") { + // Transmit and display, or put an image already transmitted + if key == "a=T" || key == "a=p" { + return true + } + } + return false + } + if after, ok := strings.CutPrefix(passThrough, "\x1b]1337;"); ok { + return strings.HasPrefix(after, "File=") || strings.HasPrefix(after, "MultipartFile=") + } + return sixelBeginRegex.MatchString(passThrough) +} + +// Whether any line carries an image +func containsImage(lines []string) bool { + for _, line := range lines { + for { + loc := findPassThrough(line) + if loc == nil { + break + } + if isImagePassThrough(line[loc[0]:loc[1]]) { + return true + } + line = line[loc[1]:] + } + } + return false +} + func extractPassThroughs(line string) ([]string, string) { passThroughs := []string{} transformed := "" diff --git a/src/terminal_test.go b/src/terminal_test.go index 8a22f054..0ece0179 100644 --- a/src/terminal_test.go +++ b/src/terminal_test.go @@ -578,6 +578,60 @@ func TestWrapPassThrough(t *testing.T) { } } +func TestIsImagePassThrough(t *testing.T) { + for _, tc := range []struct { + given string + image bool + }{ + // Kitty + {"\x1b_Ga=T,f=32,s=1258,v=1295,c=74,r=35,m=1\x1b\\", true}, + {"\x1b_Ga=p,i=1\x1b\\", true}, + // The action key is not always first, and a put carries no payload + {"\x1b_Gi=1,a=p\x1b\\", true}, + {"\x1b_Ga=p\x1b\\", true}, + {"\x1b_Ga=T,f=100\x1b\\\r", true}, + {"\x1b_Gi=1,a=d\x1b\\", false}, + {"\x1b_Ga=d,d=A\x1b\\", false}, // 'kitten icat --clear' + {"\x1b_Ga=q,i=1\x1b\\", false}, // query + {"\x1b_Gi=1,f=100\x1b\\", false}, // transmit only, the default action + {"\x1b_Gm=1;AAAA\x1b\\", false}, // continuation chunk + {"\x1b_Ga=f,i=1;AAAA\x1b\\", false}, // animation frame + {"\x1b_Ga=T,U=1,f=32;AAAA\x1b\\", true}, // unicode placeholder + // Wrapped in the tmux passthrough sequence + {"\x1bPtmux;\x1b\x1b_Ga=T,f=100\x1b\x1b\\\x1b\\", true}, + {"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", false}, + {"\x1bPtmux;\x1b\x1b_Gi=1,a=p\x1b\x1b\\\x1b\\", true}, + // iTerm2 + {"\x1b]1337;File=inline=1:AAAA\a", true}, + {"\x1b]1337;MultipartFile=inline=1\a", true}, + {"\x1b]1337;SetUserVar=foo=YmFy\a", false}, + {"\x1b]1337;CurrentDir=/tmp\a", false}, + // Sixel + {"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", true}, + {"\x1bPq#0~~\x1b\\", true}, + {"\x1bPtmux;\x1b\x1bP0;1;0q#0~~\x1b\x1b\\\x1b\\", true}, + {"", false}, + } { + if actual := isImagePassThrough(tc.given); actual != tc.image { + t.Errorf("expected %v for %q, got %v", tc.image, tc.given, actual) + } + } + + if containsImage([]string{"foo", "bar"}) { + t.Error("plain text carries no image") + } + if !containsImage([]string{"foo", "bar\x1b_Ga=T,f=100\x1b\\baz"}) { + t.Error("failed to find an image in a line") + } + if containsImage([]string{"foo\x1b_Ga=d,d=A\x1b\\"}) { + t.Error("a delete command is not an image") + } + // The image is not the first passthrough on the line + if !containsImage([]string{"\x1b_Ga=d,d=A\x1b\\text\x1b_Ga=T,f=100;AAAA\x1b\\"}) { + t.Error("failed to look past an earlier passthrough") + } +} + /* utilities section */ // Item represents one line in fzf UI. Usually it is relative path to files and folders.