From 71ed5afb3673a5b7dc652e2cd8664fb7a43436df Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:17:12 -0700 Subject: [PATCH 1/2] Compose a pasted CR/LF instead of submitting on it A terminal that never negotiates bracketed paste (DEC 2004) delivers a multi-line paste as ordinary keystrokes, carriage returns included, and a bare CR is the same "return" that sends the message. Pasting three lines was sending three separate messages instead of composing one. An unmodified Enter that lands in a keystroke burst right after a plain character is paste-carried, not a deliberate submit, so it now becomes a newline; a CRLF pair collapses to one newline instead of two. A real Ctrl+J-then-Enter (insert a line, then send) still sends, since Ctrl+J never counts as "a printable character" preceding it. --- src/tui-opentui/prompt-features.test.ts | 43 ++++++++++++++++ src/tui-opentui/shell.ts | 66 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 71908da56..022f42489 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -238,6 +238,49 @@ 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 }, + ) + }) }) describe("sent-message recall", () => { diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 432d9f872..b01400d8b 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -665,6 +665,12 @@ export type AppShell = { * ./prompt-kill-ring.js). */ promptKillRing: KillRing + /** `Date.now()` of the last keypress; detects an un-bracketed paste burst (see `PASTE_BURST_MS`). */ + lastKeyAt: number + /** Whether that last keypress inserted a plain character (see `isPrintableInsertKey`). */ + lastKeyWasPrintable: boolean + /** A converted CR is about to be followed by its CRLF partner LF; swallow that LF. */ + suppressNextLinefeed: boolean /** Images attached with Ctrl+P, sent with the next prompt submit. */ pendingAttachments: PendingImageAttachment[] /** Up/Down recall of messages already sent in this session. */ @@ -695,6 +701,23 @@ 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. Anything under this gap between keypresses is paste, not typing. +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", @@ -4666,6 +4689,46 @@ 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() : "" + + // 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 arriving right behind it. + const suppressLinefeed = shell.suppressNextLinefeed + shell.suppressNextLinefeed = false + if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) { + key.preventDefault() + return + } + + // A terminal that never negotiated bracketed paste (DEC 2004) hands a + // multi-line paste to us as ordinary keystrokes, CR and all -- and a bare + // CR is the same "return" that submits. Left alone, pasting three lines + // sends three separate messages instead of composing one. Bracketed paste + // delivers the whole blob as one `paste` event and never reaches here, so + // this only fires on the raw-keystroke fallback. + // + // 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 + // "...end of line oneline two..." arriving as raw keystrokes. + const now = Date.now() + const sincePreviousKey = now - shell.lastKeyAt + const previousKeyWasPrintable = shell.lastKeyWasPrintable + shell.lastKeyAt = now + shell.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") + shell.suppressNextLinefeed = true + return + } + const isCtrlKillYank = key.ctrl && !key.meta && @@ -4984,6 +5047,9 @@ export function createAppShell( parentStreamLog: null, parentStreamLogBase: null, promptKillRing: emptyKillRing, + lastKeyAt: 0, + lastKeyWasPrintable: false, + suppressNextLinefeed: false, pendingAttachments: [], sentHistory: createSentHistoryBrowse([]), disposed: false, From 069df2eb30f3de8835e0a10715b389637af6a487 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:34:12 -0700 Subject: [PATCH 2/2] Gate the CRLF-submit fallback on never having seen a real paste The guard from the previous commit ran on every keystroke regardless of whether the terminal actually needed it. A terminal proves it negotiates DEC 2004 the first time it fires a real paste event -- from then on every paste arrives as that one event, never as raw keystrokes, so the fallback has nothing left to guard against. Retire it for the session once that happens instead of re-running it forever; terminals that never send a paste event keep the guard, since they've never shown they can do better. Also moves the bookkeeping (last keypress time, whether it was printable, whether to swallow the next linefeed) off AppShell and into the key handler's own closure -- nothing outside that handler read it. --- src/tui-opentui/prompt-features.test.ts | 43 ++++++++++ src/tui-opentui/shell.ts | 108 ++++++++++++++---------- 2 files changed, 105 insertions(+), 46 deletions(-) diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 022f42489..350059262 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -281,6 +281,49 @@ describe("un-bracketed paste vs. deliberate Enter", () => { { 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 b01400d8b..676b7403a 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -665,12 +665,6 @@ export type AppShell = { * ./prompt-kill-ring.js). */ promptKillRing: KillRing - /** `Date.now()` of the last keypress; detects an un-bracketed paste burst (see `PASTE_BURST_MS`). */ - lastKeyAt: number - /** Whether that last keypress inserted a plain character (see `isPrintableInsertKey`). */ - lastKeyWasPrintable: boolean - /** A converted CR is about to be followed by its CRLF partner LF; swallow that LF. */ - suppressNextLinefeed: boolean /** Images attached with Ctrl+P, sent with the next prompt submit. */ pendingAttachments: PendingImageAttachment[] /** Up/Down recall of messages already sent in this session. */ @@ -703,7 +697,13 @@ export type PrimaryOverlayKind = // Human keystrokes land tens of milliseconds apart at the fastest; a paste // replayed onto stdin without bracketed-paste framing lands effectively all -// at once. Anything under this gap between keypresses is paste, not typing. +// 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. */ @@ -4542,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 @@ -4690,43 +4705,45 @@ export function createAppShell( // Ctrl+Y/Alt+Y need somewhere to yank it back from. const keyName = typeof key.name === "string" ? key.name.toLowerCase() : "" - // 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 arriving right behind it. - const suppressLinefeed = shell.suppressNextLinefeed - shell.suppressNextLinefeed = false - if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) { - key.preventDefault() - return - } + // 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 terminal that never negotiated bracketed paste (DEC 2004) hands a - // multi-line paste to us as ordinary keystrokes, CR and all -- and a bare - // CR is the same "return" that submits. Left alone, pasting three lines - // sends three separate messages instead of composing one. Bracketed paste - // delivers the whole blob as one `paste` event and never reaches here, so - // this only fires on the raw-keystroke fallback. - // - // 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 - // "...end of line oneline two..." arriving as raw keystrokes. - const now = Date.now() - const sincePreviousKey = now - shell.lastKeyAt - const previousKeyWasPrintable = shell.lastKeyWasPrintable - shell.lastKeyAt = now - shell.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") - shell.suppressNextLinefeed = true - 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 = @@ -4989,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) @@ -5047,9 +5065,6 @@ export function createAppShell( parentStreamLog: null, parentStreamLogBase: null, promptKillRing: emptyKillRing, - lastKeyAt: 0, - lastKeyWasPrintable: false, - suppressNextLinefeed: false, pendingAttachments: [], sentHistory: createSentHistoryBrowse([]), disposed: false, @@ -5059,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)