Compare commits

...
Author SHA1 Message Date
Junegunn Choi b61979793e Drop unrecognized escape sequences instead of typing them
fzf consumed only the part of a sequence it recognized, and the rest was
typed into the query. CTRL-A sent as \e[97;5u became "97;5u". A
BEL-terminated OSC reply also aborted fzf, because the BEL that followed
the typed payload was read as CTRL-G.

Frame CSI by its parameter and final byte ranges, OSC, DCS and APC by their
terminator, then drop the whole sequence when nothing matches it. The read
loop waits for a string terminator as it already did for a CSI final byte,
and the second-chance read runs only while a sequence is unfinished. After
a complete one it blocked until the next keystroke.

The hard part is telling a terminal's sequence from an ALT key:

- An unterminated sequence is left alone, since that is how ALT-[, ALT-],
  ALT-P and ALT-_ arrive. So is an empty one. A terminator straight after
  the introducer means the same thing, and framing it swallowed the ALT key,
  taking a CTRL-G abort with it when that was the next key
- An ESC further in ends the string and introduces a sequence of its own, so
  scanning on to a later ST would swallow that one
- Only OSC ends with BEL. DCS and APC end with ST, and stopping at a BEL in
  their payload framed just its first half
- A trailing ESC may be the first half of ST. Reading it as a lone ESC ended
  the wait early, and ESCDELAY=0 left nothing covering it
- SOS and PM are not framed. Nothing sends them, and waiting for a
  terminator that never comes would delay ALT-X and ALT-^
2026-09-28 00:11:18 +09:00
3 changed files with 292 additions and 4 deletions
+126 -4
View File
@@ -348,17 +348,100 @@ func getEnv(name string, defaultValue int) int {
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f } func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e } func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
// csiEnd returns the length of the CSI sequence at the start of the buffer, or
// 0 if it has no final byte yet or is malformed.
func csiEnd(buffer []byte) int {
for i := 2; i < len(buffer); i++ {
if csiFinal(buffer[i]) {
return i + 1
}
if !csiContinues(buffer[i]) {
return 0
}
}
return 0
}
// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the
// start of the buffer, up to and including whatever ended it. It returns 0 while
// nothing has ended it, and also when the terminator comes straight after the
// introducer: no terminal sends an empty string sequence, so that means the
// introducer was ALT-], ALT-P or ALT-_ with a sequence of its own behind it, and
// framing it would swallow the ALT key.
func stringEnd(buffer []byte) int {
if len(buffer) < 2 {
return 0
}
// Every one of them ends with ST. BEL ends an OSC as well, because xterm has
// always allowed it, but stopping at a BEL inside a DCS or APC payload would
// frame only its first half and leave the rest to be typed into the query.
bel := buffer[1] == ']'
for i := 2; i < len(buffer); i++ {
switch buffer[i] {
case '\a':
if bel {
if i == 2 {
return 0 // empty
}
return i + 1
}
case Esc.Byte():
if i+1 == len(buffer) {
return 0 // ST may still be arriving
}
if i == 2 {
return 0 // empty
}
if buffer[i+1] == '\\' {
return i + 2
}
// Any other ESC ends the string and introduces a sequence of its
// own, so frame only what precedes it and leave the ESC to be
// parsed again. Scanning past it would swallow that sequence too.
return i
}
}
return 0
}
// stringIntroducer reports whether the byte after ESC starts a string sequence.
// Only the three that terminals actually reply with: OSC for colors, title and
// clipboard, DCS for XTVERSION and XTGETTCAP, APC for Kitty graphics. SOS and PM
// are left out, as nothing sends them and waiting for a terminator that will
// never come would delay ALT-X and ALT-^.
func stringIntroducer(b byte) bool {
switch b {
case 'P', ']', '_':
return true
}
return false
}
// incompleteEscape reports whether the buffer ends in an escape sequence that // incompleteEscape reports whether the buffer ends in an escape sequence that
// has not been terminated yet. The read loop keeps waiting in that case, so the // has not been terminated yet. The read loop keeps waiting while it does, so a
// parser is never handed a fragment to guess at. // fragment reaches the parser only once that wait has run out.
func incompleteEscape(buffer []byte) bool { func incompleteEscape(buffer []byte) bool {
// Only the tail can hold a sequence still arriving. This runs once per byte // Only the tail can hold a sequence still arriving. This runs once per byte
// read, so scanning all of a large paste would make the read quadratic. // read, so scanning all of a large paste would make the read quadratic.
//
// The limit is that a string sequence with a payload longer than this stops
// being seen as unfinished, so one that is also split across reads reaches
// the parser incomplete and its payload is typed into the query. Recognizing
// it would mean tracking the open sequence across reads instead of
// rescanning the tail.
tail := buffer tail := buffer
if len(tail) > escapeLookback { if len(tail) > escapeLookback {
tail = tail[len(tail)-escapeLookback:] tail = tail[len(tail)-escapeLookback:]
} }
start := bytes.LastIndexByte(tail, Esc.Byte()) start := bytes.LastIndexByte(tail, Esc.Byte())
// A trailing ESC can be the first half of a string terminator. Reading it as
// a lone ESC ends the wait and hands the parser an unterminated sequence, so
// fall back to the one this ESC would have terminated.
if start == len(tail)-1 && start > 0 {
if prev := bytes.LastIndexByte(tail[:start], Esc.Byte()); prev >= 0 {
start = prev
}
}
if start < 0 || len(tail)-start < 2 { if start < 0 || len(tail)-start < 2 {
return false return false
} }
@@ -376,6 +459,9 @@ func incompleteEscape(buffer []byte) bool {
case 'O': case 'O':
return len(tail)-start < 3 return len(tail)-start < 3
} }
if stringIntroducer(tail[start+1]) {
return stringEnd(tail[start:]) == 0
}
return false return false
} }
@@ -483,8 +569,10 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
return Event{CtrlSlash, 0, nil} return Event{CtrlSlash, 0, nil}
case Esc.Byte(): case Esc.Byte():
ev := r.escSequence(&sz) ev := r.escSequence(&sz)
// Second chance // Second chance, but only for a sequence that has not finished
if ev.Type == Invalid { // arriving. Re-reading after a complete one blocks until the next
// keystroke, holding back whatever follows it in the buffer.
if ev.Type == Invalid && incompleteEscape(r.buffer) {
r.buffer, result, err = r.getBytes(true) r.buffer, result, err = r.getBytes(true)
if err != nil { if err != nil {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
@@ -525,7 +613,24 @@ func (r *LightRenderer) setCancel(f func()) {
r.mutex.Unlock() r.mutex.Unlock()
} }
// escSequence parses an escape sequence, widening a CSI sequence that
// parseEscSequence recognized the start of but gave up on partway. Consuming
// only the part that parsed would leave the rest to be read as input and typed
// into the query. Sequences that match nothing at all are dropped there instead.
func (r *LightRenderer) escSequence(sz *int) Event { func (r *LightRenderer) escSequence(sz *int) Event {
ev := r.parseEscSequence(sz)
if ev.Type != Invalid || len(r.buffer) < 3 || r.buffer[1] != '[' {
return ev
}
// Only a framed sequence is dropped. One still missing its final byte may
// yet be arriving, and the caller gives it another chance.
if end := csiEnd(r.buffer); end > *sz {
*sz = end
}
return ev
}
func (r *LightRenderer) parseEscSequence(sz *int) Event {
if len(r.buffer) < 2 { if len(r.buffer) < 2 {
return Event{Esc, 0, nil} return Event{Esc, 0, nil}
} }
@@ -987,6 +1092,23 @@ func (r *LightRenderer) escSequence(sz *int) Event {
} // r.buffer[2] } // r.buffer[2]
} // r.buffer[2] } // r.buffer[2]
} // r.buffer[1] } // r.buffer[1]
// Nothing matched. A framed sequence is dropped whole: reading its
// introducer as an ALT-key below would type the rest into the query.
// Unterminated ones are left alone, as that is how ALT-[, ALT-], ALT-P and
// ALT-_ arrive.
if r.buffer[1] == '[' {
// A bare "\e[c" is ALT-[ followed by a character, not a CSI sequence
if end := csiEnd(r.buffer); end > 3 {
*sz = end
return Event{Invalid, 0, nil}
}
} else if stringIntroducer(r.buffer[1]) {
if end := stringEnd(r.buffer); end > 0 {
*sz = end
return Event{Invalid, 0, nil}
}
}
rest := bytes.NewBuffer(r.buffer[1:]) rest := bytes.NewBuffer(r.buffer[1:])
c, size, err := rest.ReadRune() c, size, err := rest.ReadRune()
if err == nil { if err == nil {
+134
View File
@@ -0,0 +1,134 @@
package tui
import "testing"
// An unrecognized escape sequence must be consumed whole, CSI and string
// sequences alike. Consuming only part of one leaves the rest to be read as
// input and typed into the query.
func TestUnknownEscapeSequence(t *testing.T) {
for _, c := range []struct {
sequence string
event EventType
size int
}{
// Key encodings fzf does not implement
{"\x1b[97;5u", Invalid, 7},
{"\x1b[127;5u", Invalid, 8},
{"\x1b[27;5;127~", Invalid, 11},
{"\x1b[57441;1u", Invalid, 10},
{"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped
// Replies to queries fzf did not send, or sent and stopped waiting for
{"\x1b[?1;2c", Invalid, 7},
{"\x1b[>0;95;0c", Invalid, 10},
// Mouse report arriving while mouse input is off
{"\x1b[<0;1;1M", Invalid, 9},
// String sequences
{"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply
{"\x1b]0;a title\a", Invalid, 12}, // BEL terminated
{"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply
{"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply
{"\x1bP\ax\x1b\\", Invalid, 6}, // BEL is payload in a DCS
{"\x1b]foo\x1bX\x1b\\", Invalid, 5}, // ESC ends it, framing "\e]foo"
// Left alone: this is how ALT-[, ALT-], ALT-P and ALT-_ arrive
{"\x1b[a", Alt, 2},
{"\x1b]abc", Alt, 2},
{"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived
{"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key
{"\x1b]\x1b[A", Alt, 2},
{"\x1bP\x1bP", Alt, 2},
{"\x1b_\x1b_", Alt, 2},
{"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC
{"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort
// SOS and PM are not framed, so ALT-X and ALT-^ are not delayed
{"\x1bXsos\x1b\\", Alt, 2},
{"\x1b^status\x1b\\", Alt, 2},
// Left alone: no final byte yet, so the sequence may still be arriving
{"\x1b[", Invalid, 2},
// Recognized sequences keep their existing parsing
{"\x1b[1;5A", CtrlUp, 6},
{"\x1b[3;5~", CtrlDelete, 6},
{"\x1b[2~", Insert, 4},
{"\x1b[200~", BracketedPasteBegin, 6},
{"\x1b[Z", ShiftTab, 3},
{"\x1bOA", Up, 3},
{"\x1b[12;34R", Invalid, 8},
{"\x1b[?2004;2$y", Invalid, 11},
} {
r := &LightRenderer{buffer: []byte(c.sequence)}
sz := 1
event := r.escSequence(&sz)
if event.Type != c.event {
t.Errorf("escSequence(%q) = %s, want %s",
c.sequence, event.Type.String(), c.event.String())
}
if sz != c.size {
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
}
}
}
func TestStringEnd(t *testing.T) {
for _, c := range []struct {
buffer string
want int
}{
// Terminated
{"\x1b]0;t\a", 6},
{"\x1b]0;t\x1b\\", 7},
{"\x1b_G\x1b\\", 5},
{"\x1bP\ax\x1b\\", 6}, // BEL is payload in a DCS, ST ends it
// BEL terminates an OSC only
{"\x1bP\a", 0},
{"\x1b_Gi=1\a", 0},
// An ESC ends the string and introduces a sequence of its own
{"\x1b]foo\x1bX\x1b\\", 5},
{"\x1b]foo\x1bP", 5},
// Nothing has ended it yet
{"\x1b]0;t", 0},
{"\x1b]0;t\x1b", 0}, // ST half arrived
{"\x1b]foo\x1b", 0},
{"\x1b]", 0},
{"\x1b", 0}, // shorter than an introducer
// Empty, so the introducer was an ALT key and not a reply
{"\x1b]\x1b]", 0},
{"\x1b]\x1b[A", 0},
{"\x1b]\x1b\\", 0},
{"\x1b]\a", 0},
{"\x1bP\x1bP", 0},
{"\x1b_\x1b_", 0},
} {
if got := stringEnd([]byte(c.buffer)); got != c.want {
t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want)
}
}
}
func TestCsiEnd(t *testing.T) {
for _, c := range []struct {
buffer string
want int
}{
{"\x1b[97;5u", 7},
{"\x1b[A", 3},
{"\x1b[<0;1;1M", 9},
{"\x1b[?2004;2$y", 11},
{"\x1b[97;5", 0}, // no final byte
{"\x1b[", 0}, // no final byte
{"\x1b[1\x01A", 0}, // malformed, do not frame it
} {
if got := csiEnd([]byte(c.buffer)); got != c.want {
t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want)
}
}
}
+32
View File
@@ -51,3 +51,35 @@ func TestIncompleteEscape(t *testing.T) {
} }
} }
} }
// String sequences must be waited for until their terminator arrives
func TestIncompleteStringEscape(t *testing.T) {
for _, c := range []struct {
buffer string
want bool
}{
{"\x1b]11;rgb:", true},
{"\x1bP>|kitty", true},
{"\x1b_Gi=1", true},
{"\x1b]0;title\a", false},
{"\x1b]0;title\x1b\\", false},
{"\x1bP>|kitty(0.48.2)\x1b\\", false},
{"ab\x1b]11;rgb:", true},
// A string terminator split across reads: the ESC has arrived, the
// backslash has not
{"\x1b]0;t\x1b", true},
{"\x1bP>|kitty\x1b", true},
{"\x1b_G\x1b", true},
// Complete sequence followed by a lone ESC, which is the ESC key
{"\x1b]0;title\a\x1b", false},
{"\x1b\x1b", false},
{"\x1ba\x1b", false},
{"\x1b[A\x1b", false},
} {
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
}
}
}