mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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[?25l[2J[m[32m[1m[Horca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[1C]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
|
||||
[32m[1morca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K[1Cecho $(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
|
||||
[39;120H 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047
|
||||
[39;120H 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060␍[?2004h
|
||||
[32m[1morca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K[1C[8;24;80t[?25l[8;24;80t[H64[K
|
||||
65[K
|
||||
66[K
|
||||
67[K
|
||||
68[K
|
||||
69[K
|
||||
70[K
|
||||
71[K
|
||||
72[K
|
||||
73[K
|
||||
74[K
|
||||
75[K
|
||||
76[K
|
||||
77[K
|
||||
78[K
|
||||
79[K
|
||||
80[K[32m[1m
|
||||
orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$ echo $(seq 1000 1060)[K
|
||||
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[K[32m[1m
|
||||
orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K[1C[?25h[32m[1m␍orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K[1C[8;48;160t[?25l[H64[K
|
||||
65[K
|
||||
66[K
|
||||
67[K
|
||||
68[K
|
||||
69[K
|
||||
70[K
|
||||
71[K
|
||||
72[K
|
||||
73[K
|
||||
74[K
|
||||
75[K
|
||||
76[K
|
||||
77[K
|
||||
78[K
|
||||
79[K
|
||||
80[K[32m[1m
|
||||
orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$ echo $(seq 1000 1060)[K
|
||||
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[K[32m[1m
|
||||
orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K[21;64H[?25h[32m[1m␍orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K[1C[8;40;120t[?25l[H64[K
|
||||
65[K
|
||||
66[K
|
||||
67[K
|
||||
68[K
|
||||
69[K
|
||||
70[K
|
||||
71[K
|
||||
72[K
|
||||
73[K
|
||||
74[K
|
||||
75[K
|
||||
76[K
|
||||
77[K
|
||||
78[K
|
||||
79[K
|
||||
80[K[32m[1m
|
||||
orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$ echo $(seq 1000 1060)[K
|
||||
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[K[32m[1m
|
||||
orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K
|
||||
[K[22;64H[?25h[32m[1m␍orca@xxxx[m:[34m[1m/mnt/c/Users/xxxx/AppData/Local/Temp/d1b10-pty/tool[m$[K[1C
|
||||
Reference in New Issue
Block a user