From d11a47f6dffc5ef7983a61e0988814dbff9e95ea Mon Sep 17 00:00:00 2001 From: kay Date: Sun, 30 Aug 2026 16:40:34 +0900 Subject: [PATCH] Handle CSI intermediate bytes instead of printing the final byte ECMA-48 5.4 defines a control sequence as "CSI P...P I...I F". The Intermediate Bytes I (02/00 to 02/15) "together with the Final Byte F identify the control function", so they can neither be parsed as parameters nor skipped. The state machine had no state for them. On encountering an intermediate byte in EScsi it fell through to the default entry, which resets the state to ESnormal. The final byte then arrived outside of any sequence and was printed as a literal character. For example "CSI 2 SP q" (DECSCUSR) left a stray 'q' on screen at the cursor position. Applications that select a cursor shape per editing mode emit it on every cursor movement, so the character accumulated across the line. ECMA-48 table 4 lists further functions using a single intermediate byte 02/00 (SL, SR, GSM, ...), which were affected in the same way. Add EScsiInter so that intermediate bytes are tracked and the final byte is consumed as part of the sequence. This also keeps "CSI 2 SP q" (DECSCUSR) distinct from "CSI 2 q" (DECLL), which is implemented as set_led. The entry for '%' carrying the comment "otherwise the trailing 'm' gets printed" addressed the same problem for a single byte. It is moved into the new state, where the trailing 'm' reaches the SGR handler through EScsiInter. ECMA-48 assigns no function to an intermediate byte followed by 06/13, so this does not shadow a standard sequence. Section numbers in the escape_sequences[] comments were off by one from ESgreater onwards; corrected while adding the new section. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/vterm.h | 8 +++++++- src/lib/vterm_states.cpp | 29 ++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/lib/vterm.h b/src/lib/vterm.h index 08b3723..59c07f8 100644 --- a/src/lib/vterm.h +++ b/src/lib/vterm.h @@ -267,7 +267,12 @@ class VTerm { ESdcs = 8, EStermcap = 9, ESst = 10, - ESkeep = 11 + EScsiInter = 11, + // ESkeep is a sentinel meaning "leave the state unchanged", not a state + // of its own. NR_STATES is defined as its value and sizes escape_map[], + // and init_state() stops walking escape_sequences[] once the section + // counter reaches it. It must therefore stay last: add new states above. + ESkeep = 12 } EscapeState; static inline const char* EscapeStateToString(EscapeState state) { @@ -283,6 +288,7 @@ class VTerm { case ESdcs: return "DCS"; case EStermcap: return "DCS + q"; case ESst: return "ST"; + case EScsiInter: return "CSI + intermediate"; case ESkeep: return ""; } diff --git a/src/lib/vterm_states.cpp b/src/lib/vterm_states.cpp index a75ba61..c5b1c62 100644 --- a/src/lib/vterm_states.cpp +++ b/src/lib/vterm_states.cpp @@ -79,6 +79,13 @@ const VTerm::Sequence VTerm::escape_sequences[] = { { CSPAN('0', '9'), &VTerm::param_digit, ESkeep }, { ';', &VTerm::next_param, ESkeep }, { ':', &VTerm::next_param, ESkeep }, // some codes use ':' instead of ';' as the argument separator + // ECMA-48 5.4 defines a control sequence as "CSI P...P I...I F", where the + // Intermediate Bytes I (02/00 to 02/15) "together with the Final Byte F + // identify the control function". They therefore cannot be skipped: e.g. + // "CSI 2 SP q" (DECSCUSR) is a different function from "CSI 2 q" (DECLL). + // Track them in a separate state so the final byte is consumed as part of + // the sequence instead of falling through and being printed as text. + { CSPAN(' ', '/'), nullptr, EScsiInter }, { '@', &VTerm::insert_char, ESnormal }, { 'A', &VTerm::cursor_up, ESnormal }, { 'B', &VTerm::cursor_down, ESnormal }, @@ -112,7 +119,6 @@ const VTerm::Sequence VTerm::escape_sequences[] = { { '`', &VTerm::cursor_position_col, ESnormal }, { ']', &VTerm::linux_specific, ESnormal }, { '}', &VTerm::fbterm_specific, ESnormal }, - { '%', 0, ESkeep }, // this is a workaround for the wierd, undocumented code "\e[0%m" - otherwise the trailing 'm' gets printed ENDSEQ, // ESosc #3 "ESC ]" @@ -151,27 +157,40 @@ const VTerm::Sequence VTerm::escape_sequences[] = { { '9', &VTerm::screen_clear, ESnormal }, ENDSEQ, - // ESgreater #8 "ESC [ >" + // ESgreater #7 "ESC [ >" { CSPAN('0', '9'), &VTerm::param_digit, ESkeep }, { ';', &VTerm::next_param, ESkeep }, { 'c', &VTerm::get_device_attribute, ESnormal }, // Send Device Attributes (Secondary DA) { 'm', &VTerm::set_key_modifier, ESnormal }, // Set/reset key modifier options ENDSEQ, - // ESdcs #9 "ESC P" + // ESdcs #8 "ESC P" { '+', nullptr, ESkeep }, { 'q', nullptr, EStermcap }, ENDSEQ, - // EStermcap #10 "ESC P + q" + // EStermcap #9 "ESC P + q" { CSPAN('0', '9'), &VTerm::param_hex_digit, ESkeep }, { CSPAN('a', 'f'), &VTerm::param_hex_digit, ESkeep }, { CSPAN('A', 'F'), &VTerm::param_hex_digit, ESkeep }, { 0x1B, &VTerm::request_termcap, ESst }, ENDSEQ, - // ESst #11 + // ESst #10 { '\\', nullptr, ESnormal }, + ENDSEQ, + + // EScsiInter #11 "CSI ... I" where I is an intermediate byte (0x20-0x2F) + // Sequences reaching this state are not implemented by fbterm. Consume the + // remaining intermediate bytes and the final byte so that nothing is printed. + { CSPAN(' ', '/'), nullptr, ESkeep }, + { CSPAN('@', '~'), nullptr, ESnormal }, + // Exception: the undocumented "\e[0%m" is emitted by some applications and + // was previously handled by keeping '%' in EScsi so that the trailing 'm' + // reached the SGR entry. ECMA-48 assigns no function to an intermediate + // byte followed by 06/13, so treating it as SGR here does not shadow a + // standard sequence. + { 'm', &VTerm::set_display_attr, ESnormal }, ENDSEQ };