Silently consume unrecognized escape sequences

A text-mode escape interpreter can't do anything meaningful with cursor
positioning, DEC private modes, or terminal resets — but it must still
consume them, not print them as literal text. Before this change, any
sequence outside SGR / EL / OSC-8 errored out of parseOne, and view.go
rendered the unparsed bytes as visible cells. On Windows this would show
up as junk at the start of main-panel output once we add PTY support
using ConPTY, because ConPTY's session-init stream is full of such
sequences.

Three additions to the state machine:

- stateEscape: a single byte in 0x30–0x7E after ESC (e.g. ESC c = RIS)
  is a complete Fs/Fp sequence per ECMA-48; consume and reset.
- stateCSI: accept the DEC private-mode prefix bytes (<, =, >, ?), and
  accept a CSI final byte (0x40–0x7E) immediately after [ as the end of
  a zero-param sequence.
- stateParams: accept any CSI final byte we don't implement as the end
  of the sequence rather than a parse error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-04-24 12:09:10 +02:00
parent 8a8dacca14
commit 31ed34a421
2 changed files with 25 additions and 13 deletions

View file

@ -151,6 +151,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
characterEquals(ch, '+'):
ei.state = stateCharacterSetDesignation
return true, nil
case len(ch) == 1 && ch[0] >= 0x30 && ch[0] <= 0x7E:
// Single-byte ESC sequence (e.g. ESC c = RIS). We don't
// interpret these, but we must consume them so they don't
// leak into the view as literal text.
ei.state = stateNone
return true, nil
default:
return false, errNotCSI
}
@ -166,6 +172,19 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.csiParam = append(ei.csiParam, "0")
case characterEquals(ch, 'K'):
// fall through
case len(ch) == 1 && ch[0] >= 0x3C && ch[0] <= 0x3F:
// Private-mode prefix byte (<, =, >, ?). We don't interpret
// DEC private-mode sequences, but must consume them so they
// don't leak into the view as literal text. Seed an empty
// param so the subsequent digits land on a valid slot.
ei.csiParam = append(ei.csiParam, "")
ei.state = stateParams
return true, nil
case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E:
// Valid CSI final byte we don't implement — swallow.
ei.state = stateNone
ei.csiParam = nil
return true, nil
default:
return false, errCSIParseError
}
@ -203,6 +222,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.instruction = noInstruction{}
}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E:
// Valid CSI final byte we don't implement — swallow the
// whole sequence rather than printing it as text.
ei.state = stateNone
ei.csiParam = nil
return true, nil

View file

@ -166,10 +166,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
for _, input := range scenarios {
ei := newEscapeInterpreter(OutputNormal)
/* EXPECTED:
parseEscRunes(t, ei, input)
ACTUAL: */
parseEscRunesExpectingError(t, ei, input)
}
}
@ -181,13 +178,3 @@ func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) {
assert.NoError(t, err)
}
}
func parseEscRunesExpectingError(t *testing.T, ei *escapeInterpreter, runes string) {
t.Helper()
for _, b := range []byte(runes) {
if _, err := ei.parseOne([]byte{b}); err != nil {
return
}
}
t.Errorf("expected a parse error for %q, got none", runes)
}