Skip to content

Commit f22ee89

Browse files
committed
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.
1 parent c722e1f commit f22ee89

2 files changed

Lines changed: 98 additions & 45 deletions

File tree

src/tui-opentui/prompt-features.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,49 @@ describe("un-bracketed paste vs. deliberate Enter", () => {
281281
{ width: 80, height: 24 },
282282
)
283283
})
284+
285+
// The false-positive direction: once this terminal has proven it negotiates
286+
// DEC 2004 by firing one real bracketed paste, the raw-keystroke fallback
287+
// must retire for the rest of the session -- otherwise a fast typist's
288+
// genuine Enter risks being read as paste forever, on every keystroke, on
289+
// every terminal, most of which never needed the fallback at all.
290+
//
291+
// This cannot be distinguished from actual paste by timing alone: the
292+
// harness dispatches keys synchronously, so a "fast typist" and a "paste
293+
// replay" produce the identical zero-elapsed-time shape. The capability
294+
// gate is what makes the distinction possible -- this test exercises that
295+
// gate, not a timing threshold.
296+
test("a keystroke burst after a real paste no longer triggers the CRLF fallback", async () => {
297+
await withTestRenderer(
298+
async (h) => {
299+
const shell = createAppShell(h.renderer, {
300+
terminal: { columns: 80, rows: 24 },
301+
wireKeys: true,
302+
run: "idle",
303+
})
304+
try {
305+
const submitted: string[] = []
306+
setShellBridgeHooks(shell, {
307+
onSubmit: (text) => submitted.push(text),
308+
onInterrupt: () => {},
309+
exclusive: true,
310+
})
311+
shell.prompt.focus()
312+
await h.mockInput.pasteBracketedText("proves DEC 2004")
313+
shell.prompt.value = ""
314+
315+
await h.mockInput.typeText("hi\r")
316+
await h.renderOnce()
317+
318+
expect(submitted).toEqual(["hi"])
319+
expect(shell.prompt.value).toBe("")
320+
} finally {
321+
shell.dispose()
322+
}
323+
},
324+
{ width: 80, height: 24 },
325+
)
326+
})
284327
})
285328

286329
describe("sent-message recall", () => {

src/tui-opentui/shell.ts

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -665,12 +665,6 @@ export type AppShell = {
665665
* ./prompt-kill-ring.js).
666666
*/
667667
promptKillRing: KillRing
668-
/** `Date.now()` of the last keypress; detects an un-bracketed paste burst (see `PASTE_BURST_MS`). */
669-
lastKeyAt: number
670-
/** Whether that last keypress inserted a plain character (see `isPrintableInsertKey`). */
671-
lastKeyWasPrintable: boolean
672-
/** A converted CR is about to be followed by its CRLF partner LF; swallow that LF. */
673-
suppressNextLinefeed: boolean
674668
/** Images attached with Ctrl+P, sent with the next prompt submit. */
675669
pendingAttachments: PendingImageAttachment[]
676670
/** Up/Down recall of messages already sent in this session. */
@@ -4542,6 +4536,21 @@ export function createAppShell(
45424536
session = enqueue(session, `seed-${i + 1}`)
45434537
}
45444538

4539+
// A real bracketed-paste event proves this terminal negotiates DEC 2004:
4540+
// every paste from here on arrives as one `paste` event, never as raw
4541+
// keystrokes, so the CRLF-submit fallback below has nothing left to guard
4542+
// against and turns itself off for the rest of the session. Terminals that
4543+
// never send one keep the guard, since they've never shown they can do
4544+
// better. Un-bracketed-paste bookkeeping only this key handler reads, so it
4545+
// lives in this closure rather than on the shared AppShell.
4546+
let sawBracketedPaste = false
4547+
let lastKeyAt = 0
4548+
let lastKeyWasPrintable = false
4549+
let suppressNextLinefeed = false
4550+
const onPaste = (): void => {
4551+
sawBracketedPaste = true
4552+
}
4553+
45454554
const onKey = (key: KeyEvent): void => {
45464555
if (disposed) return
45474556

@@ -4690,43 +4699,45 @@ export function createAppShell(
46904699
// Ctrl+Y/Alt+Y need somewhere to yank it back from.
46914700
const keyName = typeof key.name === "string" ? key.name.toLowerCase() : ""
46924701

4693-
// The LF half of a CRLF pair the block below just turned into a newline:
4694-
// without this, "line one\r\nline two" would insert two newlines, one for
4695-
// the converted CR and one for the LF arriving right behind it.
4696-
const suppressLinefeed = shell.suppressNextLinefeed
4697-
shell.suppressNextLinefeed = false
4698-
if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) {
4699-
key.preventDefault()
4700-
return
4701-
}
4702+
// Everything below this line is the un-bracketed-paste fallback, and a
4703+
// terminal that has ever fired a real `paste` event has proven it never
4704+
// needs it: every future paste arrives as one `paste` event, not raw
4705+
// keystrokes, so re-running these checks on it would only risk a false
4706+
// positive for no benefit.
4707+
if (!sawBracketedPaste) {
4708+
// The LF half of a CRLF pair the block below just turned into a
4709+
// newline: without this, "line one\r\nline two" would insert two
4710+
// newlines, one for the converted CR and one for the LF right behind it.
4711+
const suppressLinefeed = suppressNextLinefeed
4712+
suppressNextLinefeed = false
4713+
if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) {
4714+
key.preventDefault()
4715+
return
4716+
}
47024717

4703-
// A terminal that never negotiated bracketed paste (DEC 2004) hands a
4704-
// multi-line paste to us as ordinary keystrokes, CR and all -- and a bare
4705-
// CR is the same "return" that submits. Left alone, pasting three lines
4706-
// sends three separate messages instead of composing one. Bracketed paste
4707-
// delivers the whole blob as one `paste` event and never reaches here, so
4708-
// this only fires on the raw-keystroke fallback.
4709-
//
4710-
// Detecting it needs two signals, not one: a lone fast Enter can happen
4711-
// (key rollover, a scripted "send keys"), and a lone printable character
4712-
// right before Enter is just typing. What never happens from a human is a
4713-
// printable character landing, then Enter, both inside a keystroke burst
4714-
// — that shape is unique to a paste being replayed byte-for-byte. Gating
4715-
// on both keeps a deliberate Ctrl+J-then-Enter (newline, then send) safe,
4716-
// since Ctrl+J is not "a printable character," while still catching
4717-
// "...end of line one<CR><LF>line two..." arriving as raw keystrokes.
4718-
const now = Date.now()
4719-
const sincePreviousKey = now - shell.lastKeyAt
4720-
const previousKeyWasPrintable = shell.lastKeyWasPrintable
4721-
shell.lastKeyAt = now
4722-
shell.lastKeyWasPrintable = isPrintableInsertKey(key)
4723-
const isBareReturn =
4724-
!key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter")
4725-
if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) {
4726-
key.preventDefault()
4727-
shell.prompt.insertText("\n")
4728-
shell.suppressNextLinefeed = true
4729-
return
4718+
// A bare CR is the same "return" that submits. Left alone, pasting
4719+
// three lines here sends three separate messages instead of composing
4720+
// one. Detecting it needs two signals, not one: a lone fast Enter can
4721+
// happen (key rollover, a scripted "send keys"), and a lone printable
4722+
// character right before Enter is just typing. What never happens from
4723+
// a human is a printable character landing, then Enter, both inside a
4724+
// keystroke burst -- that shape is unique to a paste being replayed
4725+
// byte-for-byte. Gating on both keeps a deliberate Ctrl+J-then-Enter
4726+
// (newline, then send) safe, since Ctrl+J is not "a printable
4727+
// character," while still catching "...line one<CR><LF>line two...".
4728+
const now = Date.now()
4729+
const sincePreviousKey = now - lastKeyAt
4730+
const previousKeyWasPrintable = lastKeyWasPrintable
4731+
lastKeyAt = now
4732+
lastKeyWasPrintable = isPrintableInsertKey(key)
4733+
const isBareReturn =
4734+
!key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter")
4735+
if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) {
4736+
key.preventDefault()
4737+
shell.prompt.insertText("\n")
4738+
suppressNextLinefeed = true
4739+
return
4740+
}
47304741
}
47314742

47324743
const isCtrlKillYank =
@@ -4989,6 +5000,7 @@ export function createAppShell(
49895000

49905001
if (wireKeys) {
49915002
renderer.keyInput.on("keypress", onKey)
5003+
renderer.keyInput.on("paste", onPaste)
49925004
prompt.onSubmit = onEnter
49935005
}
49945006
renderer.on(CliRenderEvents.FRAME, onFrame)
@@ -5047,9 +5059,6 @@ export function createAppShell(
50475059
parentStreamLog: null,
50485060
parentStreamLogBase: null,
50495061
promptKillRing: emptyKillRing,
5050-
lastKeyAt: 0,
5051-
lastKeyWasPrintable: false,
5052-
suppressNextLinefeed: false,
50535062
pendingAttachments: [],
50545063
sentHistory: createSentHistoryBrowse([]),
50555064
disposed: false,
@@ -5059,6 +5068,7 @@ export function createAppShell(
50595068
shell.disposed = true
50605069
if (wireKeys) {
50615070
renderer.keyInput.off("keypress", onKey)
5071+
renderer.keyInput.off("paste", onPaste)
50625072
prompt.onSubmit = undefined
50635073
}
50645074
renderer.off(CliRenderEvents.FRAME, onFrame)

0 commit comments

Comments
 (0)