Convert forward cursor-positioning escapes into row advances

ConPTY presents its child's output as a screen buffer and uses CUP /
CUD / CNL / VPA to skip over blank rows rather than emitting LFs. The
previous behaviour swallowed all of those and the visible content
collapsed together. Now the escape parser tracks the screen-relative
cursor row, and any CSI that moves the cursor past the current row
emits a cursorDown instruction that the view turns into the matching
number of empty lines.

Column tracking is deliberately omitted: doing it correctly would mean
duplicating the view's grapheme-cluster width math in the parser, and
ConPTY in practice positions to column 1 after a CR-equivalent, which
the existing wx-reset path already handles. ConPTY-internal scrolling
needs no special handling either: it only emits cursor-positioning
escapes within the first, un-scrolled screenful — once its screen
scrolls it switches to plain linefeeds, which the view advances on
directly regardless of the tracked cursor.

Backward cursor moves are silently dropped — the view's buffer is
append-style and can't undo earlier writes. The exception is cursor-home
(CUP to row 1): ConPTY emits it at the start of every screen, so rather
than drop it we re-anchor the row tracking to the current write position.
Without that, a view not rewound in lockstep with ConPTY's screen (the
command log, which streams pty output without a rewind) accumulates
drift, and every later absolute CUP becomes a dropped backward move that
collapses the rows ConPTY positioned with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-30 09:45:26 +02:00
parent 79bc8e0bc6
commit 180fe0cd26
4 changed files with 221 additions and 10 deletions

View file

@ -19,6 +19,21 @@ type escapeInterpreter struct {
mode OutputMode
instruction instruction
hyperlink strings.Builder
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
// rows rather than emitting LFs for them. To convert those into row
// advances the view can act on, we track where in the pseudo-terminal
// screen the cursor currently is. 1-based to match the escape
// sequences.
//
// We also have to track the column, but only well enough to count
// soft-wraps when written content runs past the right edge: ConPTY's
// CUPs are addressed against its post-wrap screen, so a logical line
// long enough to wrap in ConPTY's screen counts for two rows from the
// next CUP's perspective. Column accuracy past wrap-counting isn't
// modelled — we don't track the col argument of CUPs, and most
// pager-style emitters use col 1 anyway.
screenRow, screenCol int
}
type (
@ -32,6 +47,13 @@ type eraseInLineFromCursor struct{}
func (self eraseInLineFromCursor) isInstruction() {}
// cursorDown asks the view to advance N rows. Emitted when CUP / CUD /
// CNL / VPA targets a row past the current one; backward moves are
// ignored because the view's buffer is line-based and can't undo.
type cursorDown struct{ n int }
func (self cursorDown) isInstruction() {}
type noInstruction struct{}
func (self noInstruction) isInstruction() {}
@ -99,11 +121,15 @@ func newEscapeInterpreter(mode OutputMode) *escapeInterpreter {
curBgColor: ColorDefault,
mode: mode,
instruction: noInstruction{},
screenRow: 1,
screenCol: 1,
}
return ei
}
// reset sets the escapeInterpreter in initial state.
// reset sets the escapeInterpreter in initial state. Note: this only resets
// escape-parsing state. Screen cursor state survives so that mid-stream
// malformed escapes don't desync the row tracking from the view.
func (ei *escapeInterpreter) reset() {
ei.state = stateNone
ei.curFgColor = ColorDefault
@ -111,6 +137,80 @@ func (ei *escapeInterpreter) reset() {
ei.csiParam = nil
}
// resetScreenCursor returns the screen-cursor tracking to the top of the
// pseudo-terminal screen. Called when the view is rewound before a fresh pty
// render, and on cursor-home (which ConPTY emits at the start of each screen)
// for views that aren't rewound in lockstep — see the CUP handling in parseOne.
func (ei *escapeInterpreter) resetScreenCursor() {
ei.screenRow = 1
ei.screenCol = 1
}
// notifyRowAdvance must be called by the view whenever it advances to the
// next row in response to an LF / CRLF outside of an escape sequence
// (i.e. the row transitions the parser doesn't see directly). Keeps the
// parser's notion of the current screen row in sync with the view.
func (ei *escapeInterpreter) notifyRowAdvance() {
ei.screenRow++
ei.screenCol = 1
}
// notifyColumnReset must be called when the view processes a bare CR
// (column reset without row advance). Keeps screenCol in sync so wrap
// counting starts over from col 1.
func (ei *escapeInterpreter) notifyColumnReset() {
ei.screenCol = 1
}
// notifyCellsWritten must be called after the view writes visible cells
// to its buffer. Advances the parser's idea of the cursor by `width`
// columns; if that crosses the right edge of a `screenColMax`-wide pty
// screen, the corresponding number of soft-wraps are added to screenRow
// so subsequent CUPs land on the right line.
func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) {
if screenColMax <= 0 {
return
}
// One column at a time: matches ConPTY's "pending wrap" semantics
// where the cursor stays at col max+1 after writing the rightmost
// cell and only wraps on the next cell. Loops over individual
// columns rather than doing the math in one shot so wide cells on a
// row boundary still wrap cleanly.
for range width {
if ei.screenCol > screenColMax {
ei.screenRow++
ei.screenCol = 1
}
ei.screenCol++
}
}
// emitCursorAdvance schedules a cursorDown instruction for the next time
// the view checks ei.instruction, advancing the parser's screen row by
// the same amount. n <= 0 is a no-op (backward / same-row CUPs are
// ignored — the view's buffer is line-based and can't undo).
func (ei *escapeInterpreter) emitCursorAdvance(n int) {
if n <= 0 {
return
}
ei.instruction = cursorDown{n: n}
ei.screenRow += n
ei.screenCol = 1
}
// firstParamOrDefault returns the first CSI parameter parsed as an int,
// or dflt if it's absent / empty / unparseable.
func (ei *escapeInterpreter) firstParamOrDefault(dflt int) int {
if len(ei.csiParam) == 0 || ei.csiParam[0] == "" {
return dflt
}
n, err := strconv.Atoi(ei.csiParam[0])
if err != nil {
return dflt
}
return n
}
func (ei *escapeInterpreter) instructionRead() {
ei.instruction = noInstruction{}
}
@ -170,8 +270,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.csiParam = append(ei.csiParam, "")
case characterEquals(ch, 'm'):
ei.csiParam = append(ei.csiParam, "0")
case characterEquals(ch, 'K'):
// fall through
case characterEquals(ch, 'K'),
characterEquals(ch, 'H'), characterEquals(ch, 'f'), characterEquals(ch, 'd'),
characterEquals(ch, 'B'), characterEquals(ch, 'E'):
// fall through — let stateParams handle these with default
// params (CUP/VPA default to row 1, CUD/CNL 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
@ -240,6 +344,35 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.instruction = noInstruction{}
}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'H'), characterEquals(ch, 'f'),
characterEquals(ch, 'd'):
// CUP / HVP (absolute (row, col), col ignored) or VPA (absolute row).
targetRow := ei.firstParamOrDefault(1)
if targetRow <= 1 {
// Cursor home. ConPTY emits this (after [2J) at the start of
// every screen, so it marks where ConPTY's coordinate origin
// now sits. Re-anchor our row tracking to the current write
// position rather than treating it as a backward move: a view
// that isn't rewound in lockstep with ConPTY's screen (the
// command log) would otherwise carry stale drift, making every
// later absolute CUP compute a negative, dropped advance and
// collapsing the blank rows ConPTY positioned with.
ei.resetScreenCursor()
} else {
// Skip forward to the target row; ignore backward moves.
ei.emitCursorAdvance(targetRow - ei.screenRow)
}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'B'), characterEquals(ch, 'E'):
// CUD / CNL — relative row advance by N. CNL also resets
// the column, which we don't track, so the two are
// equivalent for our purposes.
ei.emitCursorAdvance(ei.firstParamOrDefault(1))
ei.state = stateNone
ei.csiParam = nil
return true, nil

View file

@ -153,17 +153,16 @@ func TestParseOneColours(t *testing.T) {
func TestParseOneIgnoresUnknownSequences(t *testing.T) {
// Escape sequences the interpreter doesn't implement -- whether well-formed-but-unsupported
// (cursor movement, private modes, DECSCUSR, …) or outright malformed -- must be silently
// (private modes, DECSCUSR, …) or outright malformed -- must be silently
// consumed rather than leaked into the view as literal text.
scenarios := []string{
"\x1b[?9001h", // DEC private-mode set (?-prefix)
"\x1b[?25l", // hide cursor
"\x1b[?25h", // show cursor
"\x1b[2;J", // erase display (unusual 2;J variant)
"\x1b[H", // cursor home — final byte immediately after [
"\x1b[5;1;H", // cursor position with multiple params
"\x1b[H", // cursor home — re-anchors to row 1 (no-op when already there)
"\x1bc", // RIS — single-char ESC sequence
"\x1b[;5H", // empty first param (';' immediately after '[')
"\x1b[;5H", // empty first param — defaults to row 1, no-op
"\x1b[ q", // intermediate byte with no params (DECSCUSR family)
"\x1b[0 q", // intermediate byte after a param
"\x1b[1;;m", // malformed SGR: empty middle param
@ -184,6 +183,66 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
}
}
func TestParseOneCursorPositioning(t *testing.T) {
// Cursor-positioning escapes that advance the row forward emit a
// cursorDown instruction; backward / same-row moves are ignored
// because the view's buffer is line-based.
scenarios := []struct {
input string
startRow int // parser's screenRow before parsing
wantAdvance int // 0 means "no instruction emitted"
}{
{"\x1b[5;1H", 1, 4}, // CUP — absolute row 5 from row 1
{"\x1b[5H", 1, 4}, // CUP with only the row param
{"\x1b[5;1H", 5, 0}, // CUP to the same row we're on — no-op
{"\x1b[2;1H", 5, 0}, // CUP backward — ignored
{"\x1b[5;1f", 1, 4}, // HVP alias for CUP
{"\x1b[5d", 1, 4}, // VPA — absolute row
{"\x1b[2d", 5, 0}, // VPA backward — ignored
{"\x1b[3B", 1, 3}, // CUD — relative
{"\x1b[B", 1, 1}, // CUD with default param of 1
{"\x1b[2E", 1, 2}, // CNL — relative
}
for _, s := range scenarios {
ei := newEscapeInterpreter(OutputNormal)
ei.screenRow = s.startRow
parseEscRunes(t, ei, s.input)
if s.wantAdvance == 0 {
_, noop := ei.instruction.(noInstruction)
assert.True(t, noop, "input %q at row %d should be a no-op", s.input, s.startRow)
} else {
cd, ok := ei.instruction.(cursorDown)
if assert.True(t, ok, "input %q at row %d should emit cursorDown", s.input, s.startRow) {
assert.Equal(t, s.wantAdvance, cd.n, "input %q at row %d", s.input, s.startRow)
}
}
}
}
func TestParseOneCursorHomeReanchors(t *testing.T) {
// ConPTY emits cursor-home ([H) after [2J at the start of every screen.
// In a view that isn't rewound in lockstep with ConPTY (the command log)
// screenRow has drifted, so home must re-anchor it to the current write
// position rather than be dropped as a backward move — otherwise the
// absolute CUPs that follow compute negative, dropped advances and the
// rows ConPTY positioned with collapse together.
ei := newEscapeInterpreter(OutputNormal)
ei.screenRow = 12 // accumulated drift from earlier command-log output
parseEscRunes(t, ei, "\x1b[H")
assert.Equal(t, 1, ei.screenRow, "home should re-anchor screenRow")
_, noop := ei.instruction.(noInstruction)
assert.True(t, noop, "home should not emit an instruction")
// A subsequent CUP now advances relative to the re-anchored origin.
parseEscRunes(t, ei, "\x1b[3;1H")
cd, ok := ei.instruction.(cursorDown)
if assert.True(t, ok, "CUP after home should emit cursorDown") {
assert.Equal(t, 2, cd.n)
}
}
func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) {
t.Helper()
for _, b := range []byte(runes) {

View file

@ -834,6 +834,7 @@ func (v *View) write(p []byte) {
if v.pendingNewline {
advanceToNextLine()
v.ei.notifyRowAdvance()
v.pendingNewline = false
}
@ -855,11 +856,20 @@ func (v *View) write(p []byte) {
case characterEquals(chr, '\n') || isCRLF(chr):
finishLine()
advanceToNextLine()
v.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
v.wx = 0
v.ei.notifyColumnReset()
default:
truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy)
if cd, ok := v.ei.instruction.(cursorDown); ok {
v.ei.instructionRead()
for range cd.n {
v.autoRenderHyperlinksInCurrentLine()
advanceToNextLine()
}
}
if cells == nil {
continue
}
@ -867,6 +877,17 @@ func (v *View) write(p []byte) {
if truncateLine {
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
}
// Soft-wrap tracking. truncateLine is true exactly when the
// cells are from \x1b[K filling to end of line — ConPTY
// doesn't advance the cursor for that, so we shouldn't count
// it toward wraps either.
if !truncateLine {
totalWidth := 0
for _, c := range cells {
totalWidth += c.width
}
v.ei.notifyCellsWritten(totalWidth, v.InnerWidth())
}
}
}
@ -1116,6 +1137,7 @@ func (v *View) FlushStaleCells() {
func (v *View) rewind() {
v.ei.reset()
v.ei.resetScreenCursor()
v.SetReadPos(0, 0)
v.SetWritePos(0, 0)

View file

@ -253,10 +253,7 @@ func TestWriteCursorPositionEscape(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 stringToCells(s string) []cell {