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: "); 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); + }); +});