Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/tui/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions packages/tui/test/composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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: ");
Expand Down
83 changes: 83 additions & 0 deletions packages/tui/test/paste-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Terminal>;

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<void> => 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);
});
});
Loading