fix(terminal): keep ConPTY's viewport top through a width change, checked against a captured transcript

A captured ConPTY 1.23 resize (wsl.exe) shows it keeps the line at the top
of its viewport through every resize and repaints only that viewport. xterm
keeps the cursor's rows instead: a wider grid that rejoins wrapped rows pulls
rows out of scrollback (ConPTY's repaint then overwrites them), and a
narrower one that rewraps pushes rows into it (the repaint then repeats
them). The windowsPty row rule covered neither. ConPTY-backed emulators now
put the top line back on its row after a resize.
This commit is contained in:
Neil
2026-10-26 12:52:36 -07:00
parent 9439d17068
commit ecd8f07396
5 changed files with 468 additions and 5 deletions
+140
View File
@@ -0,0 +1,140 @@
import type { Terminal } from '@xterm/headless'
type LiveBuffer = {
length: () => number
line: (index: number) => unknown
popLine: () => void
ybase: () => number
y: () => number
x: () => number
/** Moves the viewport's top up by one row into its scrollback, keeping the cursor on its text. */
showOneMoreScrollbackRow: () => void
}
// xterm keeps its live buffer on the private core; every field is checked, so a renamed one only
// falls back to xterm's own resize.
function liveBuffer(terminal: Terminal): LiveBuffer | null {
const core: unknown = '_core' in terminal ? terminal._core : null
if (typeof core !== 'object' || core === null || !('buffer' in core)) {
return null
}
const buffer: unknown = core.buffer
if (
typeof buffer !== 'object' ||
buffer === null ||
!('lines' in buffer) ||
!('ybase' in buffer) ||
!('ydisp' in buffer) ||
!('y' in buffer) ||
!('x' in buffer)
) {
return null
}
const lines: unknown = buffer.lines
if (
typeof lines !== 'object' ||
lines === null ||
!('length' in lines) ||
!('get' in lines) ||
!('pop' in lines) ||
typeof lines.get !== 'function' ||
typeof lines.pop !== 'function' ||
typeof buffer.ybase !== 'number' ||
typeof buffer.y !== 'number' ||
typeof buffer.x !== 'number'
) {
return null
}
const { get, pop } = lines
const num = (value: unknown): number => (typeof value === 'number' ? value : 0)
return {
length: () => num(lines.length),
line: (index) => get.call(lines, index),
popLine: () => {
pop.call(lines)
},
ybase: () => num(buffer.ybase),
y: () => num(buffer.y),
x: () => num(buffer.x),
showOneMoreScrollbackRow: () => {
const following = buffer.ydisp === buffer.ybase
buffer.ybase = num(buffer.ybase) - 1
if (following) {
buffer.ydisp = buffer.ybase
}
buffer.y = num(buffer.y) + 1
}
}
}
function isWrapped(line: unknown): boolean {
return typeof line === 'object' && line !== null && 'isWrapped' in line && line.isWrapped === true
}
function isBlank(line: unknown): boolean {
return (
typeof line === 'object' &&
line !== null &&
'getTrimmedLength' in line &&
typeof line.getTrimmedLength === 'function' &&
line.getTrimmedLength() === 0 &&
!isWrapped(line)
)
}
/**
* Resizes as the terminal's PTY does. Unix PTYs follow xterm. ConPTY keeps the line at the top of its viewport through a resize and repaints
* that viewport from the top. xterm instead keeps the cursor's rows in place: a wider grid that
* rejoins wrapped rows pulls rows back out of scrollback, and a narrower one that rewraps pushes
* rows into it. ConPTY's repaint then overwrites the pulled rows or repeats the pushed ones, so put
* the top line back on its row: pulled rows return to scrollback with blank rows below, and pushed
* rows come back while blank rows below the cursor make room.
*/
export function resizeLikeOwnPty(
terminal: Terminal,
cols: number,
rows: number,
writeSync: (data: string) => void
): void {
const conpty = terminal.options.windowsPty?.backend === 'conpty'
const before = conpty && terminal.buffer.active.type === 'normal' ? liveBuffer(terminal) : null
if (!before) {
terminal.resize(cols, rows)
return
}
// A logical line's first row survives reflow as the same object; continuation rows may not.
let anchorIndex = before.ybase()
while (anchorIndex < before.length() - 1 && isWrapped(before.line(anchorIndex))) {
anchorIndex++
}
const anchor = before.line(anchorIndex)
const anchorRow = anchorIndex - before.ybase()
terminal.resize(cols, rows)
const after = liveBuffer(terminal)
if (!after) {
return
}
let index = -1
for (let i = 0; i < after.length(); i++) {
if (after.line(i) === anchor) {
index = i
break
}
}
if (index < 0) {
return
}
const shift = index - after.ybase() - anchorRow
if (shift > 0) {
const cursorRow = Math.max(1, after.y() + 1 - shift)
writeSync(`\x1b[${rows};1H${'\n'.repeat(shift)}\x1b[${cursorRow};${after.x() + 1}H`)
return
}
for (let pushed = -shift; pushed > 0 && after.ybase() > 0; pushed--) {
if (after.y() >= rows - 1 || !isBlank(after.line(after.length() - 1))) {
return
}
after.popLine()
after.showOneMoreScrollbackRow()
}
}
+3 -5
View File
@@ -8,6 +8,7 @@ import {
serializeWithAbsoluteCursor
} from '../../shared/terminal-serialize-absolute-cursor'
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
import { resizeLikeOwnPty } from './conpty-reflow-viewport'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../shared/terminal-scrollback-policy'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
@@ -227,15 +228,12 @@ export class HeadlessEmulator {
}
resize(cols: number, rows: number): void {
if (this.disposed) {
return
}
// Preserve row-indexed restored links on same-size resizes; only real resizes invalidate them.
if (this.terminal.cols === cols && this.terminal.rows === rows) {
if (this.disposed || (this.terminal.cols === cols && this.terminal.rows === rows)) {
return
}
this.restoredOscLinks = []
this.terminal.resize(cols, rows)
resizeLikeOwnPty(this.terminal, cols, rows, (data) => this.writeSync(data))
}
// Report applied dimensions so the renderer can detect a dropped resize.
@@ -0,0 +1,108 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import type { PtyOwnerBackend } from '../../shared/pty-owner-backend'
import { Session } from './session'
const FIXTURES = join(__dirname, '../runtime/__fixtures__')
const TRANSCRIPT = readFileSync(join(FIXTURES, 'conpty-1-23-wsl-resize-repaint.txt'), 'utf8')
const META: {
cols: number
rows: number
resizes: { cols: number; rows: number; byteOffset: number }[]
} = JSON.parse(readFileSync(join(FIXTURES, 'conpty-1-23-wsl-resize-repaint.meta.json'), 'utf8'))
const LONG_LINE = Array.from({ length: 61 }, (_, i) => String(1000 + i)).join(' ')
function createMockSubprocess() {
let onData: ((data: string) => void) | null = null
return {
pid: 12345,
getForegroundProcess: (): string | null => null,
write(_data: string) {},
resize(_cols: number, _rows: number) {},
kill() {},
terminateOwnedTree: () => 'unavailable' as const,
forceKill() {},
signal(_sig: string) {},
onData(cb: (data: string) => void) {
onData = cb
},
onExit(_cb: (code: number) => void) {},
dispose() {},
simulateData(data: string) {
onData?.(data)
}
}
}
let session: Session | null = null
afterEach(() => {
session?.dispose()
session = null
})
/** Logical lines of the daemon's saved screen, with soft-wrapped rows joined back together. */
async function savedLogicalLines(ownerBackend: PtyOwnerBackend): Promise<string[]> {
const subprocess = createMockSubprocess()
session = new Session({
sessionId: 'conpty-transcript',
cols: META.cols,
rows: META.rows,
subprocess,
shellReadySupported: false,
ownerBackend
})
// The transcript's offsets count bytes; it is ASCII apart from escapes, so they match string indexes.
let offset = 0
for (const resize of META.resizes) {
subprocess.simulateData(TRANSCRIPT.slice(offset, resize.byteOffset))
session.resize(resize.cols, resize.rows)
offset = resize.byteOffset
}
subprocess.simulateData(TRANSCRIPT.slice(offset))
await session.settleShellOwnershipConfirmation()
const snapshot = session.takePendingOutput(true, { teardownSnapshot: true })?.snapshot
expect(snapshot).toBeTruthy()
const terminal = new Terminal({
cols: snapshot!.cols,
rows: snapshot!.rows,
scrollback: 5000,
allowProposedApi: true
})
await new Promise<void>((resolve) =>
terminal.write(`${snapshot!.rehydrateSequences}${snapshot!.snapshotAnsi}`, resolve)
)
const lines: string[] = []
for (let i = 0; i < terminal.buffer.active.length; i++) {
const line = terminal.buffer.active.getLine(i)
const text = line?.translateToString(true) ?? ''
if (line?.isWrapped && lines.length > 0) {
lines[lines.length - 1] += text
} else {
lines.push(text)
}
}
terminal.dispose()
return lines.map((line) => line.trimEnd())
}
// Captured from ConPTY 1.23 hosting wsl.exe: each resize repaints only ConPTY's own viewport from
// the top, never the rows that scrolled off, and the 302-column line arrives unbroken.
describe('daemon screen through a captured ConPTY resize transcript', () => {
it('keeps every line once, in order, with no joined prompts and the long line whole', async () => {
const lines = await savedLogicalLines('windows-wsl')
const numbers = lines.filter((line) => /^\d+$/.test(line)).map(Number)
expect(numbers).toEqual(Array.from({ length: 80 }, (_, i) => i + 1))
expect(lines.filter((line) => line === LONG_LINE)).toHaveLength(1)
expect(lines.filter((line) => /\$.*orca@/.test(line))).toEqual([])
})
// Control: the Unix grow model pulls history back under ConPTY's repaint.
it('loses or repeats lines without the ConPTY model', async () => {
const lines = await savedLogicalLines('posix-pty')
const numbers = lines.filter((line) => /^\d+$/.test(line)).map(Number)
expect(numbers).not.toEqual(Array.from({ length: 80 }, (_, i) => i + 1))
})
})
@@ -0,0 +1,29 @@
{
"capturedAt": "2026-10-26T06:03:17.947Z",
"platform": "win32",
"command": ["wsl.exe", "-d", "<test-distro>"],
"cols": 120,
"rows": 40,
"resizes": [
{
"atMs": 6000,
"cols": 80,
"rows": 24,
"byteOffset": 1095
},
{
"atMs": 10000,
"cols": 160,
"rows": 48,
"byteOffset": 1858
},
{
"atMs": 14000,
"cols": 120,
"rows": 40,
"byteOffset": 2751
}
],
"note": "ConPTY 1.23 (bundled conpty.dll) hosting wsl.exe: seq 1 80, then a 302-column line, then resizes 120x40 -> 80x24 -> 160x48 -> 120x40; user and host names scrubbed",
"exitCode": 0
}
@@ -0,0 +1,188 @@
[?9001h[?1004h[?2004h[?25lorca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$]0;orca@xxxx: /mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[?25h[?2004lseq 1 80
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80␍[?2004h
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$echo $(seq 1000 1060)␍[?2004l
1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023
 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047
 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060␍[?2004h
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$[?25l64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$ echo $(seq 1000 1060)
1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$[?25h␍orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$[?25l64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$ echo $(seq 1000 1060)
1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$


























[?25h␍orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$[?25l64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$ echo $(seq 1000 1060)
1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060
orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$

















[?25h␍orca@xxxx:/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool$