Skip to content

Commit 069df2e

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 71ed5af commit 069df2e

2 files changed

Lines changed: 105 additions & 46 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: 62 additions & 46 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. */
@@ -703,7 +697,13 @@ export type PrimaryOverlayKind =
703697

704698
// Human keystrokes land tens of milliseconds apart at the fastest; a paste
705699
// replayed onto stdin without bracketed-paste framing lands effectively all
706-
// at once. Anything under this gap between keypresses is paste, not typing.
700+
// at once. 15ms is an empirical guess at a gap comfortably under normal
701+
// typing and comfortably over a replayed paste, not a measured figure --
702+
// too high false-positives on a very fast typist's real Enter (read as
703+
// paste, so it inserts a newline instead of sending); too low misses a
704+
// slow paste replay (read as typing, so a bare CR mid-paste still
705+
// submits). Only matters before this terminal's first real paste event;
706+
// see `sawBracketedPaste` below.
707707
const PASTE_BURST_MS = 15
708708

709709
/** A single unmodified character, as opposed to a control chord or named key. */
@@ -4542,6 +4542,21 @@ export function createAppShell(
45424542
session = enqueue(session, `seed-${i + 1}`)
45434543
}
45444544

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

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

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-
}
4708+
// Everything below this line is the un-bracketed-paste fallback, and a
4709+
// terminal that has ever fired a real `paste` event has proven it never
4710+
// needs it: every future paste arrives as one `paste` event, not raw
4711+
// keystrokes, so re-running these checks on it would only risk a false
4712+
// positive for no benefit.
4713+
if (!sawBracketedPaste) {
4714+
// The LF half of a CRLF pair the block below just turned into a
4715+
// newline: without this, "line one\r\nline two" would insert two
4716+
// newlines, one for the converted CR and one for the LF right behind it.
4717+
const suppressLinefeed = suppressNextLinefeed
4718+
suppressNextLinefeed = false
4719+
if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) {
4720+
key.preventDefault()
4721+
return
4722+
}
47024723

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
4724+
// A bare CR is the same "return" that submits. Left alone, pasting
4725+
// three lines here sends three separate messages instead of composing
4726+
// one. Detecting it needs two signals, not one: a lone fast Enter can
4727+
// happen (key rollover, a scripted "send keys"), and a lone printable
4728+
// character right before Enter is just typing. What never happens from
4729+
// a human is a printable character landing, then Enter, both inside a
4730+
// keystroke burst -- that shape is unique to a paste being replayed
4731+
// byte-for-byte. Gating on both keeps a deliberate Ctrl+J-then-Enter
4732+
// (newline, then send) safe, since Ctrl+J is not "a printable
4733+
// character," while still catching "...line one<CR><LF>line two...".
4734+
const now = Date.now()
4735+
const sincePreviousKey = now - lastKeyAt
4736+
const previousKeyWasPrintable = lastKeyWasPrintable
4737+
lastKeyAt = now
4738+
lastKeyWasPrintable = isPrintableInsertKey(key)
4739+
const isBareReturn =
4740+
!key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter")
4741+
if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) {
4742+
key.preventDefault()
4743+
shell.prompt.insertText("\n")
4744+
suppressNextLinefeed = true
4745+
return
4746+
}
47304747
}
47314748

47324749
const isCtrlKillYank =
@@ -4989,6 +5006,7 @@ export function createAppShell(
49895006

49905007
if (wireKeys) {
49915008
renderer.keyInput.on("keypress", onKey)
5009+
renderer.keyInput.on("paste", onPaste)
49925010
prompt.onSubmit = onEnter
49935011
}
49945012
renderer.on(CliRenderEvents.FRAME, onFrame)
@@ -5047,9 +5065,6 @@ export function createAppShell(
50475065
parentStreamLog: null,
50485066
parentStreamLogBase: null,
50495067
promptKillRing: emptyKillRing,
5050-
lastKeyAt: 0,
5051-
lastKeyWasPrintable: false,
5052-
suppressNextLinefeed: false,
50535068
pendingAttachments: [],
50545069
sentHistory: createSentHistoryBrowse([]),
50555070
disposed: false,
@@ -5059,6 +5074,7 @@ export function createAppShell(
50595074
shell.disposed = true
50605075
if (wireKeys) {
50615076
renderer.keyInput.off("keypress", onKey)
5077+
renderer.keyInput.off("paste", onPaste)
50625078
prompt.onSubmit = undefined
50635079
}
50645080
renderer.off(CliRenderEvents.FRAME, onFrame)

0 commit comments

Comments
 (0)