From 31ed34a4214adbb68e5612063b036eebeb97de86 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Apr 2026 12:09:10 +0200 Subject: [PATCH] Silently consume unrecognized escape sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gocui/escape.go | 25 +++++++++++++++++++++++++ pkg/gocui/escape_test.go | 13 ------------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index cb557f088..da55830aa 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -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 diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index 3d9ab5b36..d41f009d2 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -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) -}