Skip to content

Commit 71ed5af

Browse files
committed
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.
1 parent 7753fd5 commit 71ed5af

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,49 @@ describe("text paste", () => {
238238
async (h) => await h.mockInput.pasteBracketedText(`${"x".repeat(4000)}\nend`),
239239
`${"x".repeat(4000)}\nend`,
240240
)
241+
242+
// A terminal that never negotiated DEC 2004 hands a paste to us as plain
243+
// keystrokes -- CR included -- instead of one `paste` event. Without a
244+
// burst guard, the bare CR after "line one" would hit the same submit
245+
// binding a deliberate Enter does, sending the message after its first
246+
// line instead of composing all three.
247+
pasteCase(
248+
"a CRLF paste arriving as raw keystrokes still composes instead of submitting",
249+
async (h) => await h.mockInput.typeText("line one\r\nline two\r\nline three"),
250+
"line one\nline two\nline three",
251+
)
252+
})
253+
254+
describe("un-bracketed paste vs. deliberate Enter", () => {
255+
test("Ctrl+J then Enter still sends -- a newline chord followed by a real Enter is not a paste", async () => {
256+
await withTestRenderer(
257+
async (h) => {
258+
const shell = createAppShell(h.renderer, {
259+
terminal: { columns: 80, rows: 24 },
260+
wireKeys: true,
261+
run: "idle",
262+
})
263+
try {
264+
const submitted: string[] = []
265+
setShellBridgeHooks(shell, {
266+
onSubmit: (text) => submitted.push(text),
267+
onInterrupt: () => {},
268+
exclusive: true,
269+
})
270+
shell.prompt.focus()
271+
shell.prompt.value = "first"
272+
h.mockInput.pressKey("\n")
273+
h.mockInput.pressKey("\r")
274+
await h.renderOnce()
275+
expect(submitted).toEqual(["first\n"])
276+
expect(shell.prompt.value).toBe("")
277+
} finally {
278+
shell.dispose()
279+
}
280+
},
281+
{ width: 80, height: 24 },
282+
)
283+
})
241284
})
242285

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

src/tui-opentui/shell.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,12 @@ 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
668674
/** Images attached with Ctrl+P, sent with the next prompt submit. */
669675
pendingAttachments: PendingImageAttachment[]
670676
/** Up/Down recall of messages already sent in this session. */
@@ -695,6 +701,23 @@ export type PrimaryOverlayKind =
695701
| "mcp"
696702
| "plugin_credentials"
697703

704+
// Human keystrokes land tens of milliseconds apart at the fastest; a paste
705+
// replayed onto stdin without bracketed-paste framing lands effectively all
706+
// at once. Anything under this gap between keypresses is paste, not typing.
707+
const PASTE_BURST_MS = 15
708+
709+
/** A single unmodified character, as opposed to a control chord or named key. */
710+
function isPrintableInsertKey(key: KeyEvent): boolean {
711+
return (
712+
!key.ctrl &&
713+
!key.meta &&
714+
!key.option &&
715+
typeof key.sequence === "string" &&
716+
key.sequence.length === 1 &&
717+
key.sequence >= " "
718+
)
719+
}
720+
698721
const DEFAULT_TITLE = "corbits"
699722
const DEFAULT_OVERLAY_ITEMS = [
700723
"Allow bash: ls",
@@ -4666,6 +4689,46 @@ export function createAppShell(
46664689
// kill ring — Ctrl+K/U/W and Alt+D delete natively but discard the text;
46674690
// Ctrl+Y/Alt+Y need somewhere to yank it back from.
46684691
const keyName = typeof key.name === "string" ? key.name.toLowerCase() : ""
4692+
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+
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
4730+
}
4731+
46694732
const isCtrlKillYank =
46704733
key.ctrl &&
46714734
!key.meta &&
@@ -4984,6 +5047,9 @@ export function createAppShell(
49845047
parentStreamLog: null,
49855048
parentStreamLogBase: null,
49865049
promptKillRing: emptyKillRing,
5050+
lastKeyAt: 0,
5051+
lastKeyWasPrintable: false,
5052+
suppressNextLinefeed: false,
49875053
pendingAttachments: [],
49885054
sentHistory: createSentHistoryBrowse([]),
49895055
disposed: false,

0 commit comments

Comments
 (0)