Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/tui-opentui/prompt-features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,92 @@ 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 },
)
})

// 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", () => {
Expand Down
82 changes: 82 additions & 0 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,29 @@ 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. 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. */
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",
Expand Down Expand Up @@ -4519,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

Expand Down Expand Up @@ -4666,6 +4704,48 @@ 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() : ""

// 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 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 one<CR><LF>line 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 =
key.ctrl &&
!key.meta &&
Expand Down Expand Up @@ -4926,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)
Expand Down Expand Up @@ -4993,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)
Expand Down
Loading