From 0d455a98291cc7cc56e5d561da83eb679feb7515 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 16 Jul 2026 08:30:42 +0000 Subject: [PATCH] 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. --- plugins/terminals/src/node/backend.ts | 16 +++++++++++++++- plugins/terminals/test/terminals.test.ts | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/plugins/terminals/src/node/backend.ts b/plugins/terminals/src/node/backend.ts index c580ac97..33ff1239 100644 --- a/plugins/terminals/src/node/backend.ts +++ b/plugins/terminals/src/node/backend.ts @@ -148,8 +148,22 @@ export function spawnPipe(options: SpawnBackendOptions): TerminalProcess { const exitCbs: ((code: number) => void)[] = [] let exited = false + // A piped child has no controlling TTY, so its stdout/stderr carry bare `\n` + // line endings — a real PTY would apply the kernel's ONLCR translation. xterm + // only returns the cursor to column 0 on `\r`, so forwarding bare `\n` renders + // a staircase. Translate lone `\n` to `\r\n`, tracking a `\r` left dangling at + // a chunk boundary so an existing `\r\n` split across chunks isn't doubled. + let pendingCr = false + const normalizeNewlines = (data: string): string => { + const out = data.replace(/\r?\n/g, (match, offset: number) => + match === '\n' && !(offset === 0 && pendingCr) ? '\r\n' : match) + pendingCr = out.endsWith('\r') + return out + } + const emitData = (data: string): void => { - for (const cb of dataCbs) cb(data) + const normalized = normalizeNewlines(data) + for (const cb of dataCbs) cb(normalized) } const emitExit = (code: number): void => { if (exited) diff --git a/plugins/terminals/test/terminals.test.ts b/plugins/terminals/test/terminals.test.ts index e9487d54..4cccfe3c 100644 --- a/plugins/terminals/test/terminals.test.ts +++ b/plugins/terminals/test/terminals.test.ts @@ -62,6 +62,26 @@ describe('@devframes/plugin-terminals', () => { }) }) + it('normalizes bare LF from a piped readonly session to CRLF', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + // A piped child has no TTY to apply ONLCR, so it emits bare `\n`. Without + // normalization xterm renders a staircase; the backend must translate lone + // `\n` to `\r\n` while leaving an existing `\r\n` untouched. + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdout.write("a\\nb\\r\\nc")'], + mode: 'readonly', + }) + + const reader = subscribe(client, info.id) + const output = await collectUntil(reader, acc => acc.includes('a') && acc.includes('b') && acc.includes('c')) + expect(output).toContain('a\r\nb\r\nc') + expect(output).not.toContain('a\nb') + expect(output).not.toContain('\r\r\n') + }) + it('rejects writes to a readonly session', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50))