Restore bracketed paste mode instead of forcing it off
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s

Ask the terminal whether the mode is already on (DECRQM) and put it back
that way on exit. Forcing it off broke pasting in shells that run fzf
from a line editor widget, which enable the mode only when the editor
starts. Terminals that do not answer fall back to disabling it.

- Startup queries go out in one write, cursor position last. Every
  terminal answers DSR, so its reply bounds the wait: a paste reply
  still missing by then means the terminal does not know the query.
- Bound the first read with select(2). Terminals that never answer
  escape sequences, such as FreeBSD virtual terminals, blocked startup
  until a key was pressed, and that keystroke was then discarded.

Fix #4887
Fix #2860
Fix #976
This commit is contained in:
Junegunn Choi committed 2026-08-10 22:36:25 +09:00
1 parent 2885df8395
commit dab626bd9e
4 files changed
+168 -19

No files matched your search

+56 -5
View File
@@ -24,15 +24,38 @@ const (
defaultEscDelay = 100
escPollInterval = 5
offsetPollTries = 10
queryTimeout = 500 * time.Millisecond
maxInputBuffer = 1024 * 1024
maxSelectTries = 100
)
const DefaultTtyDevice string = "/dev/tty"
var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
var offsetRegexp = regexp.MustCompile("\x00?\x1b\\[([0-9]+);([0-9]+)R")
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
// DECRPM reply to the DECRQM query for bracketed paste mode. Ps is 1 or 3 when
// the mode was already set, 2 or 4 when reset, 0 when the terminal does not
// recognize the mode.
var pasteModeRegexp = regexp.MustCompile("\x00?\x1b\\[\\?2004;([0-4])\\$y")
var pasteModeRegexpBegin = regexp.MustCompile("^\x1b\\[\\?2004;[0-4]\\$y")
// A report to ask the terminal for, and the reply to recognize it by.
type termQuery struct {
seq string
reply *regexp.Regexp
}
// What we ask the terminal at startup, in the order the queries go out. A
// terminal answers them in that order, so the cursor position query is last and
// also ends the wait: every terminal fzf supports answers it, so once its reply
// arrives, a query still unanswered is one the terminal does not know rather
// than one we stopped waiting for too early.
var startupQueries = []termQuery{
{"?2004$p", pasteModeRegexp},
{"6n", offsetRegexp},
}
func (r *LightRenderer) Bell() {
r.flushRaw("\a")
}
@@ -158,6 +181,10 @@ type LightRenderer struct {
showCursor bool
mutex sync.Mutex
// Whether bracketed paste was already on before we enabled it. Nil when
// the terminal did not answer the query.
pasteWasSet *bool
// Windows only
ttyinChannel chan byte
inHandle uintptr
@@ -230,8 +257,13 @@ func (r *LightRenderer) Init() error {
if r.fullscreen {
r.smcup()
} else {
y, x := r.findOffset()
}
// Ask everything in one round trip, before the offset is needed.
y, x, pasteWasSet := r.queryStartup()
r.pasteWasSet = pasteWasSet
if !r.fullscreen {
r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small
// time frame between fzf processes where the user keystrokes are not
@@ -318,7 +350,11 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
if c == Esc.Int() || nonblock {
retries = r.escDelay / escPollInterval
}
buffer = append(buffer, byte(c))
// A non-blocking read that found nothing has no byte to record. Recording
// one would put a NUL in the middle of a reply still being assembled.
if result.ok() {
buffer = append(buffer, byte(c))
}
pc := c
for {
@@ -446,6 +482,12 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{Invalid, 0, nil}
}
loc = pasteModeRegexpBegin.FindIndex(r.buffer)
if loc != nil && loc[0] == 0 {
*sz = loc[1]
return Event{Invalid, 0, nil}
}
*sz = 2
if r.buffer[1] == 8 {
return Event{CtrlAltBackspace, 0, nil}
@@ -1019,7 +1061,16 @@ func (r *LightRenderer) disableMouse() {
func (r *LightRenderer) disableModes() {
r.disableMouse()
r.csi("?2004l")
// Put bracketed paste back the way we found it. A shell that runs fzf from
// a line editor widget re-enables the mode only when the editor starts, so
// forcing it off here would leave it off for the rest of the session.
// Terminals that did not answer the query fall back to disabling, which is
// what fzf has always done.
if r.pasteWasSet != nil && *r.pasteWasSet {
r.csi("?2004h")
} else {
r.csi("?2004l")
}
}
func (r *LightRenderer) Resume(clear bool, sigcont bool) {