Skip to content

Commit 5354401

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 cf3bb84 commit 5354401

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
@@ -207,6 +207,49 @@ describe("text paste", () => {
207207
async (h) => await h.mockInput.pasteBracketedText(`${"x".repeat(4000)}\nend`),
208208
`${"x".repeat(4000)}\nend`,
209209
)
210+
211+
// A terminal that never negotiated DEC 2004 hands a paste to us as plain
212+
// keystrokes -- CR included -- instead of one `paste` event. Without a
213+
// burst guard, the bare CR after "line one" would hit the same submit
214+
// binding a deliberate Enter does, sending the message after its first
215+
// line instead of composing all three.
216+
pasteCase(
217+
"a CRLF paste arriving as raw keystrokes still composes instead of submitting",
218+
async (h) => await h.mockInput.typeText("line one\r\nline two\r\nline three"),
219+
"line one\nline two\nline three",
220+
)
221+
})
222+
223+
describe("un-bracketed paste vs. deliberate Enter", () => {
224+
test("Ctrl+J then Enter still sends -- a newline chord followed by a real Enter is not a paste", async () => {
225+
await withTestRenderer(
226+
async (h) => {
227+
const shell = createAppShell(h.renderer, {
228+
terminal: { columns: 80, rows: 24 },
229+
wireKeys: true,
230+
run: "idle",
231+
})
232+
try {
233+
const submitted: string[] = []
234+
setShellBridgeHooks(shell, {
235+
onSubmit: (text) => submitted.push(text),
236+
onInterrupt: () => {},
237+
exclusive: true,
238+
})
239+
shell.prompt.focus()
240+
shell.prompt.value = "first"
241+
h.mockInput.pressKey("\n")
242+
h.mockInput.pressKey("\r")
243+
await h.renderOnce()
244+
expect(submitted).toEqual(["first\n"])
245+
expect(shell.prompt.value).toBe("")
246+
} finally {
247+
shell.dispose()
248+
}
249+
},
250+
{ width: 80, height: 24 },
251+
)
252+
})
210253
})
211254

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

src/tui-opentui/shell.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,12 @@ export type AppShell = {
652652
* ./prompt-kill-ring.js).
653653
*/
654654
promptKillRing: KillRing
655+
/** `Date.now()` of the last keypress; detects an un-bracketed paste burst (see `PASTE_BURST_MS`). */
656+
lastKeyAt: number
657+
/** Whether that last keypress inserted a plain character (see `isPrintableInsertKey`). */
658+
lastKeyWasPrintable: boolean
659+
/** A converted CR is about to be followed by its CRLF partner LF; swallow that LF. */
660+
suppressNextLinefeed: boolean
655661
/** Images attached with Ctrl+P, sent with the next prompt submit. */
656662
pendingAttachments: PendingImageAttachment[]
657663
/** Up/Down recall of messages already sent in this session. */
@@ -682,6 +688,23 @@ export type PrimaryOverlayKind =
682688
| "mcp"
683689
| "plugin_credentials"
684690

691+
// Human keystrokes land tens of milliseconds apart at the fastest; a paste
692+
// replayed onto stdin without bracketed-paste framing lands effectively all
693+
// at once. Anything under this gap between keypresses is paste, not typing.
694+
const PASTE_BURST_MS = 15
695+
696+
/** A single unmodified character, as opposed to a control chord or named key. */
697+
function isPrintableInsertKey(key: KeyEvent): boolean {
698+
return (
699+
!key.ctrl &&
700+
!key.meta &&
701+
!key.option &&
702+
typeof key.sequence === "string" &&
703+
key.sequence.length === 1 &&
704+
key.sequence >= " "
705+
)
706+
}
707+
685708
const DEFAULT_TITLE = "corbits"
686709
const DEFAULT_OVERLAY_ITEMS = [
687710
"Allow bash: ls",
@@ -4586,6 +4609,46 @@ export function createAppShell(
45864609
// kill ring — Ctrl+K/U/W and Alt+D delete natively but discard the text;
45874610
// Ctrl+Y/Alt+Y need somewhere to yank it back from.
45884611
const keyName = typeof key.name === "string" ? key.name.toLowerCase() : ""
4612+
4613+
// The LF half of a CRLF pair the block below just turned into a newline:
4614+
// without this, "line one\r\nline two" would insert two newlines, one for
4615+
// the converted CR and one for the LF arriving right behind it.
4616+
const suppressLinefeed = shell.suppressNextLinefeed
4617+
shell.suppressNextLinefeed = false
4618+
if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) {
4619+
key.preventDefault()
4620+
return
4621+
}
4622+
4623+
// A terminal that never negotiated bracketed paste (DEC 2004) hands a
4624+
// multi-line paste to us as ordinary keystrokes, CR and all -- and a bare
4625+
// CR is the same "return" that submits. Left alone, pasting three lines
4626+
// sends three separate messages instead of composing one. Bracketed paste
4627+
// delivers the whole blob as one `paste` event and never reaches here, so
4628+
// this only fires on the raw-keystroke fallback.
4629+
//
4630+
// Detecting it needs two signals, not one: a lone fast Enter can happen
4631+
// (key rollover, a scripted "send keys"), and a lone printable character
4632+
// right before Enter is just typing. What never happens from a human is a
4633+
// printable character landing, then Enter, both inside a keystroke burst
4634+
// — that shape is unique to a paste being replayed byte-for-byte. Gating
4635+
// on both keeps a deliberate Ctrl+J-then-Enter (newline, then send) safe,
4636+
// since Ctrl+J is not "a printable character," while still catching
4637+
// "...end of line one<CR><LF>line two..." arriving as raw keystrokes.
4638+
const now = Date.now()
4639+
const sincePreviousKey = now - shell.lastKeyAt
4640+
const previousKeyWasPrintable = shell.lastKeyWasPrintable
4641+
shell.lastKeyAt = now
4642+
shell.lastKeyWasPrintable = isPrintableInsertKey(key)
4643+
const isBareReturn =
4644+
!key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter")
4645+
if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) {
4646+
key.preventDefault()
4647+
shell.prompt.insertText("\n")
4648+
shell.suppressNextLinefeed = true
4649+
return
4650+
}
4651+
45894652
const isCtrlKillYank =
45904653
key.ctrl &&
45914654
!key.meta &&
@@ -4902,6 +4965,9 @@ export function createAppShell(
49024965
observe: null,
49034966
parentStreamLog: null,
49044967
promptKillRing: emptyKillRing,
4968+
lastKeyAt: 0,
4969+
lastKeyWasPrintable: false,
4970+
suppressNextLinefeed: false,
49054971
pendingAttachments: [],
49064972
sentHistory: createSentHistoryBrowse([]),
49074973
disposed: false,

0 commit comments

Comments
 (0)