diff --git a/js/terminal.js b/js/terminal.js index f8014ba1..40a3146a 100644 --- a/js/terminal.js +++ b/js/terminal.js @@ -205,12 +205,17 @@ function runRootTerminal(term) { term.tabOptions = []; term.tabBase = ""; - // Optimize: just write the character instead of redrawing entire line - if (e.length === 1 && e.charCodeAt(0) >= 32) { + // xterm sends pasted text as one payload, so normalize it before insert. + const input = e + .replace(/[\r\n\t]/g, " ") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + + if (input.length > 0) { const pos = term.pos(); const restOfLine = term.currentLine.slice(pos); - term.currentLine = term.currentLine.slice(0, pos) + e + restOfLine; - term.write(e); + term.currentLine = + term.currentLine.slice(0, pos) + input + restOfLine; + term.write(input); if (restOfLine.length > 0) { term.write(restOfLine); term.write("\x1b[D".repeat(restOfLine.length)); diff --git a/tests/terminal.test.js b/tests/terminal.test.js index 1a046e51..d08eaa9b 100644 --- a/tests/terminal.test.js +++ b/tests/terminal.test.js @@ -95,6 +95,83 @@ describe("runRootTerminal", () => { expect(term.scrollToBottom).toHaveBeenCalled(); }); + it("inserts multi-character pasted input", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term._onData("help"); + + expect(term.currentLine).toBe("help"); + expect(term.write).toHaveBeenCalledWith("help"); + }); + + it("inserts pasted input at the cursor before trailing text", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm({ + currentLine: "heo", + pos: vi.fn(() => 2), + }); + + runRootTerminal(term); + term._onData("ll"); + + expect(term.currentLine).toBe("hello"); + expect(term.write).toHaveBeenNthCalledWith(1, "ll"); + expect(term.write).toHaveBeenNthCalledWith(2, "o"); + expect(term.write).toHaveBeenNthCalledWith(3, "\x1b[D"); + }); + + it("converts pasted newlines to spaces without executing the command", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term._onData("whois\nroot"); + + expect(term.currentLine).toBe("whois root"); + expect(term.write).toHaveBeenCalledWith("whois root"); + expect(term.executeCommandLine).not.toHaveBeenCalled(); + }); + + it("ignores pasted control-only input", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term._onData("\u0000\u001f\u007f"); + + expect(term.currentLine).toBe(""); + expect(term.write).not.toHaveBeenCalled(); + expect(term.executeCommandLine).not.toHaveBeenCalled(); + }); + + it("ignores pasted input while locked", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term.locked = true; + term._onData("help"); + + expect(term.currentLine).toBe(""); + expect(term.write).not.toHaveBeenCalled(); + expect(term.scrollToBottom).not.toHaveBeenCalled(); + }); + + it("ignores pasted input while busy", () => { + const { runRootTerminal } = loadTerminalScript(); + const term = createTerm(); + + runRootTerminal(term); + term.busy = true; + term._onData("help"); + + expect(term.currentLine).toBe(""); + expect(term.write).not.toHaveBeenCalled(); + expect(term.scrollToBottom).not.toHaveBeenCalled(); + }); + it("debounces resize handling with requestAnimationFrame", () => { const rafCallbacks = []; const requestAnimationFrame = vi.fn((callback) => {