From 88c7a2f8a63411982b9585b22e85a8c8b3508f92 Mon Sep 17 00:00:00 2001 From: nimbly1 <258366730+nimbly1@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:23:24 -0500 Subject: [PATCH 1/2] fix(tui): normalize CR and CRLF line endings in a paste xterm.js turns a clipboard's line breaks into CR before bracketed paste, so Composer.paste() inserted raw CRs. A CR moves the cursor to the start of the row when drawn, so later lines overwrote earlier ones and the cursor no longer matched the draft. paste() now converts CRLF and lone CR to LF before counting lines, collapsing long pastes and inserting, so all three line-ending variants produce the same draft, placeholder line count and prompt-history entry. Ordinary Enter handling is unchanged. Fixes #30 Co-Authored-By: Claude Sonnet 5.5 --- packages/tui/src/composer.ts | 6 ++++- packages/tui/test/composer.test.ts | 39 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/composer.ts b/packages/tui/src/composer.ts index 0f1135c..6d5a798 100644 --- a/packages/tui/src/composer.ts +++ b/packages/tui/src/composer.ts @@ -78,8 +78,12 @@ export class Composer { * Claude Code's behaviour, and for the same reason: a pasted stack trace * or file turns the composer into a wall the person cannot see their own * words in. The text goes to the model whole when the draft is sent. + * + * Line breaks are normalized to LF first: terminals send a pasted break as + * CR (xterm.js) or CRLF, and a raw CR would overwrite rows when drawn. */ - paste(text: string): void { + paste(raw: string): void { + const text = raw.replace(/\r\n?/g, "\n"); const lines = text.split("\n").length; if (lines <= PASTE_COLLAPSE_LINES && text.length <= PASTE_COLLAPSE_CHARS) { this.insert(text); diff --git a/packages/tui/test/composer.test.ts b/packages/tui/test/composer.test.ts index 4ef0b03..cdfa88a 100644 --- a/packages/tui/test/composer.test.ts +++ b/packages/tui/test/composer.test.ts @@ -94,6 +94,36 @@ describe("editing", () => { expect(c.text).toBe("two\nlines"); }); + // xterm.js turns a clipboard's line breaks into CR before bracketed paste, so a + // raw CR (or CRLF) reaches the composer and would overwrite rows when drawn. + it("normalizes CR and CRLF line endings in a paste to the same draft as LF", () => { + const lines = ["첫째 줄 한글🙂", "둘째 줄 café 漢字", "셋째 줄 cursor"]; + const expected = lines.join("\n"); + for (const eol of ["\n", "\r\n", "\r"]) { + const c = new Composer(); + c.paste(lines.join(eol)); + expect(c.text).toBe(expected); + expect(c.cursor).toBe([...expected].length); + c.left(); + expect(c.cursor).toBe([...expected].length - 1); + expect(c.submit()).toBe(expected); + } + const mixed = new Composer(); + mixed.paste("a\r\nb\rc\nd"); + expect(mixed.text).toBe("a\nb\nc\nd"); + expect(mixed.text).not.toContain("\r"); + }); + + it("counts a long CR or CRLF paste in normalized lines and sends the normalized text", () => { + const lines = Array.from({ length: 30 }, (_, i) => `line ${i}`); + for (const eol of ["\n", "\r\n", "\r"]) { + const c = new Composer(); + c.paste(lines.join(eol)); + expect(c.text).toBe("[paste #1: 30 lines] "); + expect(c.submit()).toBe(lines.join("\n")); + } + }); + it("trims trailing whitespace on submit and skips repeats in history", () => { const c = new Composer(); c.insert("task \n"); @@ -232,6 +262,15 @@ describe("pasted prompt history", () => { expect(c.historyEntries).toEqual([block]); }); + it("recalls a CRLF paste as the same normalized text", () => { + const c = new Composer(); + c.paste(block.replace(/\n/g, "\r\n")); + expect(c.submit()).toBe(block); + expect(c.up()).toBe(true); + expect(c.submit()).toBe(block); + expect(c.historyEntries).toEqual([block]); + }); + it("round-trips normal text and multiple pasted blocks", () => { const c = new Composer(); c.insert("first: "); From fa954995a2ba5b3e8b77f845443db69cb1f4a517 Mon Sep 17 00:00:00 2001 From: nimbly1 <258366730+nimbly1@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:43:24 -0500 Subject: [PATCH 2/2] test(tui): paint normalized pastes in a headless terminal xterm.js delivers a clipboard line break as CR inside bracketed paste. Drawing that byte moves the cursor to the start of the row, so later lines cover earlier ones. These tests decode a real bracketed paste, hand it to the composer, and paint it with Screen into a headless terminal. CR, CRLF, and LF stay on separate rows, the cursor stays on the last line, and a short paste can still be edited and submitted. --- packages/tui/test/paste-layout.test.ts | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/tui/test/paste-layout.test.ts diff --git a/packages/tui/test/paste-layout.test.ts b/packages/tui/test/paste-layout.test.ts new file mode 100644 index 0000000..ac7eb79 --- /dev/null +++ b/packages/tui/test/paste-layout.test.ts @@ -0,0 +1,83 @@ +/** + * Bracketed paste arrives with the line endings the terminal sent. + * xterm.js turns a clipboard break into CR before the paste markers, and a + * CR written into a row moves the cursor to the start of that row. After + * normalization the three lines stay visible and the cursor sits on the last. + */ + +import xterm from "@xterm/headless"; +import { describe, expect, it } from "vitest"; +import { Composer } from "../src/composer.js"; +import { KeyDecoder } from "../src/keys.js"; +import { Screen } from "../src/screen.js"; + +const { Terminal } = xterm; +type TerminalInstance = InstanceType; + +const ESC = "\x1b"; +const LINES = ["첫째 줄 한글🙂", "둘째 줄 café 漢字", "셋째 줄 cursor"]; + +function visible(terminal: TerminalInstance): string[] { + const buffer = terminal.buffer.active; + return Array.from({ length: terminal.rows }, (_, index) => + buffer.getLine(buffer.baseY + index)?.translateToString(true) ?? ""); +} + +/** Paint one bracketed paste the way the chat loop does: decode, paste, draw. */ +async function paintPaste(eol: string): Promise<{ text: string; cursorX: number; cursorLine: string }> { + const cols = 80; + const rows = 24; + const chunks: string[] = []; + const terminal = new Terminal({ cols, rows, convertEol: true, allowProposedApi: true }); + const screen = new Screen({ + write: (text) => chunks.push(text), + columns: () => cols, + rows: () => rows, + interactive: true, + }); + const flush = (): Promise => new Promise((resolve) => terminal.write(chunks.splice(0).join(""), resolve)); + try { + const pasted = LINES.join(eol); + const keys = new KeyDecoder().feed(`${ESC}[200~${pasted}${ESC}[201~`); + expect(keys).toEqual([{ type: "paste", text: pasted }]); + const key = keys[0]!; + if (key.type !== "paste") throw new Error("expected a paste"); + const composer = new Composer(); + composer.paste(key.text); + screen.setComposer({ draft: composer.snapshot() }); + await flush(); + const buffer = terminal.buffer.active; + return { + text: visible(terminal).join("\n"), + cursorX: buffer.cursorX, + cursorLine: buffer.getLine(buffer.baseY + buffer.cursorY)?.translateToString(true) ?? "", + }; + } finally { + screen.finish(); + terminal.dispose(); + } +} + +describe("bracketed paste on screen", () => { + it("keeps CR, CRLF, and LF pastes on separate rows with the cursor on the last line", async () => { + const painted = []; + for (const eol of ["\n", "\r\n", "\r"]) painted.push(await paintPaste(eol)); + for (const view of painted) { + for (const line of LINES) expect(view.text).toContain(line); + expect(view.text).not.toContain("^M"); + expect(view.cursorLine).toContain(LINES[2]); + } + expect(painted[1]).toEqual(painted[0]); + expect(painted[2]).toEqual(painted[0]); + }); + + it("lets a short CR paste be edited and submitted without a raw carriage return", () => { + const composer = new Composer(); + composer.paste(LINES.join("\r")); + composer.backspace(); + const expected = `${LINES[0]}\n${LINES[1]}\n${LINES[2]!.slice(0, -1)}`; + expect(composer.text).toBe(expected); + expect(composer.text).not.toContain("\r"); + expect(composer.submit()).toBe(expected); + }); +});