fix(terminal): gate Ctrl+Enter CSI-u on a negotiated kitty pane

Ctrl+Enter emitted \x1b[13;5u unconditionally, so a pane that never
negotiated the kitty keyboard protocol (local Windows ConPTY, plain
shell) printed the escape verbatim into the prompt. Mirror the
Shift+Enter guard and fall back to the legacy CR every emulator sends
for this chord. Keeps the intercept, so IME commit ordering and the
single-send dedupe still apply.

Fixes #12329

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-06 19:01:04 -07:00
committed by OrcaWin
co-authored by Orca
parent e68831f32c
commit 4ae8e80d12
5 changed files with 67 additions and 30 deletions
@@ -105,6 +105,19 @@ describe('resolvePreviewShortcutAction', () => {
).toEqual({ type: 'sendInput', data: '\x1b\r' })
})
it('gates the Ctrl+Enter CSI-u chord on the preview pty kitty flags', () => {
expect(
resolvePreviewShortcutAction(
keydown({ key: 'Enter', ctrlKey: true }),
contextFor({ kitty: true })
)
).toEqual({ type: 'sendInput', data: '\x1b[13;5u' })
// Why: kittyKeyboardAdvertised is only an advertisement — live flags decide (#12329).
expect(
resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), contextFor())
).toEqual({ type: 'sendInput', data: '\r' })
})
it('reports pane-scoped chords so the caller can swallow them', () => {
expect(
resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor())
@@ -270,27 +270,42 @@ describe('resolveTerminalShortcutAction', () => {
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2)
})
it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => {
// Why: xterm.js collapses Ctrl+Enter to a bare CR; intercept upstream and
// emit the kitty sequence (modifier code 5 = Ctrl) so probing TUIs receive
// the distinct chord on every platform.
expect(
resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), true)
).toEqual({ type: 'sendInput', data: '\x1b[13;5u' })
expect(
resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false)
).toEqual({ type: 'sendInput', data: '\x1b[13;5u' })
// Windows uses the same kitty sequence for now: no TUI is known to treat the
// CSI-u Ctrl+Enter form as inert (cf. the Shift+Enter Codex-on-PowerShell case).
expect(
it('forwards Ctrl+Enter as the kitty CSI-u chord only on a negotiated kitty pane', () => {
// Why: xterm.js collapses Ctrl+Enter to a bare CR; intercept upstream and emit the
// kitty sequence (modifier code 5 = Ctrl) so probing TUIs receive the distinct chord.
// Why: #12329 — CSI-u is application input; a pane that never negotiated it (local
// Windows ConPTY, plain shell) prints it verbatim, so send the legacy CR instead.
const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const)
const csiU = { type: 'sendInput', data: '\x1b[13;5u' }
const legacyCr = { type: 'sendInput', data: '\r' }
const resolveCtrlEnter = (isMac: boolean, isWindows: boolean, kittyActive: boolean) =>
resolveTerminalShortcutAction(
event({ key: 'Enter', code: 'Enter', ctrlKey: true }),
false,
isMac,
'false',
0,
true
isWindows,
undefined,
undefined,
() => kittyActive,
undefined,
getWindowsShiftEnterEncoding,
() => true
)
).toEqual({ type: 'sendInput', data: '\x1b[13;5u' })
for (const [isMac, isWindows] of [
[true, false],
[false, false],
[false, true]
] as const) {
expect(resolveCtrlEnter(isMac, isWindows, true)).toEqual(csiU)
expect(resolveCtrlEnter(isMac, isWindows, false)).toEqual(legacyCr)
}
// A Droid pane's Shift+Enter encoding is no authority over Ctrl+Enter.
expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled()
// Absent tracker (surfaces that report no flags) must not send CSI-u either.
expect(
resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false)
).toEqual(legacyCr)
// Modifier combos that are NOT plain Ctrl+Enter must keep falling through.
expect(
@@ -195,8 +195,12 @@ export function resolveTerminalShortcutAction(
!event.shiftKey &&
event.key === 'Enter'
) {
// Why: xterm.js collapses Ctrl+Enter to a bare CR, so forward kitty CSI-u (modifier 5 = Ctrl) so the chord reaches TUIs; no Windows fallback yet (#2418).
return { type: 'sendInput', data: '\x1b[13;5u' }
// Why: CSI-u (modifier 5 = Ctrl) only reads as a chord while KKP is negotiated; without it the pane prints it verbatim (#12329).
// Why: fall back to the CR xterm.js would have collapsed to, but keep intercepting so IME commit ordering and dedupe still apply.
return {
type: 'sendInput',
data: isKittyKeyboardActivePane?.() === true ? '\x1b[13;5u' : '\r'
}
}
if (
@@ -379,24 +379,25 @@ async function assertShiftOutcome(page: Page): Promise<void> {
])
}
// Why: this harness never negotiates the kitty protocol, so Ctrl+Enter falls back to
// the legacy CR (#12329) instead of printing CSI-u verbatim into the prompt.
async function assertCtrlOutcome(page: Page): Promise<void> {
await expect
.poll(() => readReceived(page), {
timeout: 10_000,
message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord'
})
.toBe('하 하 하\u001b[13;5u')
.toBe('하 하 하\r')
await expect
.poll(() => readPromptLine(page), {
timeout: 10_000,
message: 'prompt must show the committed syllables followed by exactly one CSI-u chord'
message: 'prompt must be empty — no literal escape bytes may survive the chord'
})
.toBe('하 하 하<ESC>[13;5u')
.toBe('')
await page.waitForTimeout(500)
expect(await readPromptLine(page), 'Ctrl+Enter must produce exactly one CSI-u chord').toBe(
'하 하 하<ESC>[13;5u'
)
expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([])
expect(await readSubmitted(page), 'Ctrl+Enter must produce exactly one newline').toEqual([
'하 하 하'
])
}
const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [
@@ -416,8 +417,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [
modifiers: 2,
assertOutcome: assertCtrlOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b[13;5u\r',
submitted: ['하 하 하\u001b[13;5u']
received: '하 하 하\r\r',
submitted: ['하 하 하', '']
}
},
{
@@ -455,8 +456,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [
windowsOnly: true,
assertOutcome: assertCtrlOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b[13;5u\r',
submitted: ['하 하 하\u001b[13;5u']
received: '하 하 하\r\r',
submitted: ['하 하 하', '']
}
}
]
+5 -1
View File
@@ -526,13 +526,17 @@ test.describe('Terminal Shortcuts', () => {
await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;')
})
test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({
test('Ctrl+Enter writes the kitty modified-enter chord only once the TUI negotiates it', async ({
orcaPage,
electronApp
}) => {
await installMainProcessPtyWriteSpy(electronApp)
await waitForActivePanePtyId(orcaPage)
// Why: a plain shell never negotiates kitty, so CSI-u would print verbatim (#12329).
await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r')
await enableKittyKeyboardReporting(orcaPage, 31)
await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u')
})