Skip to content

Commit 0d455a9

Browse files
committed
fix(terminals): normalize bare LF to CRLF in piped readonly output
A piped child process has no controlling TTY, so its stdout/stderr carry bare \n line endings that xterm renders as a staircase (no cursor return to column 0). Translate lone \n to \r\n in spawnPipe, mirroring the kernel's ONLCR translation, while leaving existing \r\n untouched.
1 parent 5e5b2c7 commit 0d455a9

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

plugins/terminals/src/node/backend.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,22 @@ export function spawnPipe(options: SpawnBackendOptions): TerminalProcess {
148148
const exitCbs: ((code: number) => void)[] = []
149149
let exited = false
150150

151+
// A piped child has no controlling TTY, so its stdout/stderr carry bare `\n`
152+
// line endings — a real PTY would apply the kernel's ONLCR translation. xterm
153+
// only returns the cursor to column 0 on `\r`, so forwarding bare `\n` renders
154+
// a staircase. Translate lone `\n` to `\r\n`, tracking a `\r` left dangling at
155+
// a chunk boundary so an existing `\r\n` split across chunks isn't doubled.
156+
let pendingCr = false
157+
const normalizeNewlines = (data: string): string => {
158+
const out = data.replace(/\r?\n/g, (match, offset: number) =>
159+
match === '\n' && !(offset === 0 && pendingCr) ? '\r\n' : match)
160+
pendingCr = out.endsWith('\r')
161+
return out
162+
}
163+
151164
const emitData = (data: string): void => {
152-
for (const cb of dataCbs) cb(data)
165+
const normalized = normalizeNewlines(data)
166+
for (const cb of dataCbs) cb(normalized)
153167
}
154168
const emitExit = (code: number): void => {
155169
if (exited)

plugins/terminals/test/terminals.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ describe('@devframes/plugin-terminals', () => {
6262
})
6363
})
6464

65+
it('normalizes bare LF from a piped readonly session to CRLF', async () => {
66+
const client = bootClient(server.port)
67+
await new Promise(r => setTimeout(r, 50))
68+
69+
// A piped child has no TTY to apply ONLCR, so it emits bare `\n`. Without
70+
// normalization xterm renders a staircase; the backend must translate lone
71+
// `\n` to `\r\n` while leaving an existing `\r\n` untouched.
72+
const info = await call<TerminalSessionInfo>(client, 'devframes-plugin-terminals:spawn', {
73+
command: NODE,
74+
args: ['-e', 'process.stdout.write("a\\nb\\r\\nc")'],
75+
mode: 'readonly',
76+
})
77+
78+
const reader = subscribe(client, info.id)
79+
const output = await collectUntil(reader, acc => acc.includes('a') && acc.includes('b') && acc.includes('c'))
80+
expect(output).toContain('a\r\nb\r\nc')
81+
expect(output).not.toContain('a\nb')
82+
expect(output).not.toContain('\r\r\n')
83+
})
84+
6585
it('rejects writes to a readonly session', async () => {
6686
const client = bootClient(server.port)
6787
await new Promise(r => setTimeout(r, 50))

0 commit comments

Comments
 (0)