Materialize cursor-forward escapes as space runs

ConPTY compresses runs of default-colored spaces into ECH + CUF
(\x1b[NX\x1b[NC) instead of emitting them literally. ECH is still a
no-op for us — our buffer is built sequentially and has nothing to
erase — but CUF has to materialize as N visible space cells so the
gap actually appears, otherwise content the child wrote with leading
indentation slides left against the preceding cell.

The view's cursorForward branch reuses the same machinery as tab
expansion: substitute the trigger byte for a space and let the
repeatCount path emit the cells under the parser-tracked SGR. The
existing notifyCellsWritten plumbing then advances screenCol over
the gap, keeping subsequent CUP targets aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-07-03 18:47:15 +02:00
co-authored by Claude Opus 4.7
parent 0c0c50c3f2
commit 2fce9c91b6
4 changed files with 51 additions and 6 deletions
+20 -3
View File
@@ -54,6 +54,14 @@ type cursorDown struct{ n int }
func (self cursorDown) isInstruction() {}
// cursorForward asks the view to materialize N space cells. Emitted
// when CUF advances the cursor right — ConPTY uses CUF (often paired
// with ECH) to encode runs of default-colored spaces compactly, so we
// have to render the gap, not just bump a counter.
type cursorForward struct{ n int }
func (self cursorForward) isInstruction() {}
type noInstruction struct{}
func (self noInstruction) isInstruction() {}
@@ -272,10 +280,11 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.csiParam = append(ei.csiParam, "0")
case characterEquals(ch, 'K'),
characterEquals(ch, 'H'), characterEquals(ch, 'f'), characterEquals(ch, 'd'),
characterEquals(ch, 'B'), characterEquals(ch, 'E'):
characterEquals(ch, 'B'), characterEquals(ch, 'E'),
characterEquals(ch, 'C'):
// fall through — let stateParams handle these with default
// params (CUP/VPA default to row 1, CUD/CNL default to advance
// by 1).
// params (CUP/VPA default to row 1, CUD/CNL/CUF default to
// advance by 1).
case characterEquals(ch, ';'):
// Empty first param ([;Xm ≡ [0;Xm). Seed a slot for the
// empty param; stateParams will append the next one when it
@@ -376,6 +385,14 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'C'):
// CUF — cursor forward N. Emit space cells so the gap
// renders. (screenCol is updated by the view via
// notifyCellsWritten as those spaces are emitted.)
ei.instruction = cursorForward{n: ei.firstParamOrDefault(1)}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case len(ch) == 1 && ch[0] >= 0x20 && ch[0] <= 0x2F:
// CSI intermediate byte after params. The final byte will
// have a semantic we don't implement (e.g. `[0 q` =
+23
View File
@@ -243,6 +243,29 @@ func TestParseOneCursorHomeReanchors(t *testing.T) {
}
}
func TestParseOneCursorForward(t *testing.T) {
// CUF (\x1b[NC) emits a cursorForward instruction so the view can
// materialize the N-cell gap as spaces. ConPTY uses this (often
// paired with ECH) to encode runs of default-colored spaces.
scenarios := []struct {
input string
wantN int
}{
{"\x1b[5C", 5},
{"\x1b[1C", 1},
{"\x1b[C", 1}, // no param defaults to 1
}
for _, s := range scenarios {
ei := newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, s.input)
cf, ok := ei.instruction.(cursorForward)
if assert.True(t, ok, "input %q should emit cursorForward", s.input) {
assert.Equal(t, s.wantN, cf.n, "input %q", s.input)
}
}
}
func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) {
t.Helper()
for _, b := range []byte(runes) {
+8
View File
@@ -1003,6 +1003,14 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
bg: v.ei.curBgColor,
}
return truncateLine, []cell{}
} else if cf, ok := v.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
// it emits as ECH+CUF instead of literal whitespace).
v.ei.instructionRead()
repeatCount = cf.n
ch = []byte{' '}
width = 1
} else if isEscape {
// do not output anything
return truncateLine, nil
-3
View File
@@ -297,10 +297,7 @@ func TestWriteCursorForwardEscape(t *testing.T) {
got = append(got, cellsToStrings(l.cells))
}
/* EXPECTED:
assert.Equal(t, [][]string{{"a", " ", " ", " ", " ", " ", "b"}}, got)
ACTUAL: */
assert.Equal(t, [][]string{{"a", "b"}}, got)
}
func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {