diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 71908da56..350059262 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -238,6 +238,92 @@ describe("text paste", () => { async (h) => await h.mockInput.pasteBracketedText(`${"x".repeat(4000)}\nend`), `${"x".repeat(4000)}\nend`, ) + + // A terminal that never negotiated DEC 2004 hands a paste to us as plain + // keystrokes -- CR included -- instead of one `paste` event. Without a + // burst guard, the bare CR after "line one" would hit the same submit + // binding a deliberate Enter does, sending the message after its first + // line instead of composing all three. + pasteCase( + "a CRLF paste arriving as raw keystrokes still composes instead of submitting", + async (h) => await h.mockInput.typeText("line one\r\nline two\r\nline three"), + "line one\nline two\nline three", + ) +}) + +describe("un-bracketed paste vs. deliberate Enter", () => { + test("Ctrl+J then Enter still sends -- a newline chord followed by a real Enter is not a paste", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + }) + try { + const submitted: string[] = [] + setShellBridgeHooks(shell, { + onSubmit: (text) => submitted.push(text), + onInterrupt: () => {}, + exclusive: true, + }) + shell.prompt.focus() + shell.prompt.value = "first" + h.mockInput.pressKey("\n") + h.mockInput.pressKey("\r") + await h.renderOnce() + expect(submitted).toEqual(["first\n"]) + expect(shell.prompt.value).toBe("") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + // The false-positive direction: once this terminal has proven it negotiates + // DEC 2004 by firing one real bracketed paste, the raw-keystroke fallback + // must retire for the rest of the session -- otherwise a fast typist's + // genuine Enter risks being read as paste forever, on every keystroke, on + // every terminal, most of which never needed the fallback at all. + // + // This cannot be distinguished from actual paste by timing alone: the + // harness dispatches keys synchronously, so a "fast typist" and a "paste + // replay" produce the identical zero-elapsed-time shape. The capability + // gate is what makes the distinction possible -- this test exercises that + // gate, not a timing threshold. + test("a keystroke burst after a real paste no longer triggers the CRLF fallback", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + }) + try { + const submitted: string[] = [] + setShellBridgeHooks(shell, { + onSubmit: (text) => submitted.push(text), + onInterrupt: () => {}, + exclusive: true, + }) + shell.prompt.focus() + await h.mockInput.pasteBracketedText("proves DEC 2004") + shell.prompt.value = "" + + await h.mockInput.typeText("hi\r") + await h.renderOnce() + + expect(submitted).toEqual(["hi"]) + expect(shell.prompt.value).toBe("") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) }) describe("sent-message recall", () => { diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 432d9f872..676b7403a 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -695,6 +695,29 @@ export type PrimaryOverlayKind = | "mcp" | "plugin_credentials" +// Human keystrokes land tens of milliseconds apart at the fastest; a paste +// replayed onto stdin without bracketed-paste framing lands effectively all +// at once. 15ms is an empirical guess at a gap comfortably under normal +// typing and comfortably over a replayed paste, not a measured figure -- +// too high false-positives on a very fast typist's real Enter (read as +// paste, so it inserts a newline instead of sending); too low misses a +// slow paste replay (read as typing, so a bare CR mid-paste still +// submits). Only matters before this terminal's first real paste event; +// see `sawBracketedPaste` below. +const PASTE_BURST_MS = 15 + +/** A single unmodified character, as opposed to a control chord or named key. */ +function isPrintableInsertKey(key: KeyEvent): boolean { + return ( + !key.ctrl && + !key.meta && + !key.option && + typeof key.sequence === "string" && + key.sequence.length === 1 && + key.sequence >= " " + ) +} + const DEFAULT_TITLE = "corbits" const DEFAULT_OVERLAY_ITEMS = [ "Allow bash: ls", @@ -4519,6 +4542,21 @@ export function createAppShell( session = enqueue(session, `seed-${i + 1}`) } + // A real bracketed-paste event proves this terminal negotiates DEC 2004: + // every paste from here on arrives as one `paste` event, never as raw + // keystrokes, so the CRLF-submit fallback below has nothing left to guard + // against and turns itself off for the rest of the session. Terminals that + // never send one keep the guard, since they've never shown they can do + // better. Un-bracketed-paste bookkeeping only this key handler reads, so it + // lives in this closure rather than on the shared AppShell. + let sawBracketedPaste = false + let lastKeyAt = 0 + let lastKeyWasPrintable = false + let suppressNextLinefeed = false + const onPaste = (): void => { + sawBracketedPaste = true + } + const onKey = (key: KeyEvent): void => { if (disposed) return @@ -4666,6 +4704,48 @@ export function createAppShell( // kill ring — Ctrl+K/U/W and Alt+D delete natively but discard the text; // Ctrl+Y/Alt+Y need somewhere to yank it back from. const keyName = typeof key.name === "string" ? key.name.toLowerCase() : "" + + // Everything below this line is the un-bracketed-paste fallback, and a + // terminal that has ever fired a real `paste` event has proven it never + // needs it: every future paste arrives as one `paste` event, not raw + // keystrokes, so re-running these checks on it would only risk a false + // positive for no benefit. + if (!sawBracketedPaste) { + // The LF half of a CRLF pair the block below just turned into a + // newline: without this, "line one\r\nline two" would insert two + // newlines, one for the converted CR and one for the LF right behind it. + const suppressLinefeed = suppressNextLinefeed + suppressNextLinefeed = false + if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) { + key.preventDefault() + return + } + + // A bare CR is the same "return" that submits. Left alone, pasting + // three lines here sends three separate messages instead of composing + // one. Detecting it needs two signals, not one: a lone fast Enter can + // happen (key rollover, a scripted "send keys"), and a lone printable + // character right before Enter is just typing. What never happens from + // a human is a printable character landing, then Enter, both inside a + // keystroke burst -- that shape is unique to a paste being replayed + // byte-for-byte. Gating on both keeps a deliberate Ctrl+J-then-Enter + // (newline, then send) safe, since Ctrl+J is not "a printable + // character," while still catching "...line oneline two...". + const now = Date.now() + const sincePreviousKey = now - lastKeyAt + const previousKeyWasPrintable = lastKeyWasPrintable + lastKeyAt = now + lastKeyWasPrintable = isPrintableInsertKey(key) + const isBareReturn = + !key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter") + if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) { + key.preventDefault() + shell.prompt.insertText("\n") + suppressNextLinefeed = true + return + } + } + const isCtrlKillYank = key.ctrl && !key.meta && @@ -4926,6 +5006,7 @@ export function createAppShell( if (wireKeys) { renderer.keyInput.on("keypress", onKey) + renderer.keyInput.on("paste", onPaste) prompt.onSubmit = onEnter } renderer.on(CliRenderEvents.FRAME, onFrame) @@ -4993,6 +5074,7 @@ export function createAppShell( shell.disposed = true if (wireKeys) { renderer.keyInput.off("keypress", onKey) + renderer.keyInput.off("paste", onPaste) prompt.onSubmit = undefined } renderer.off(CliRenderEvents.FRAME, onFrame)