fix(terminal): send ESC d for Option+Forward-Delete on macOS (#21491) (#33296)

macOS: Option+Forward-Delete sends ESC d (delete word forward) for shells and inline agent TUIs, so Claude Code deletes a word instead of the whole line; full-screen non-agent apps (vim, nano, less) keep the native CSI 3;3~. Linux/Windows unchanged. Fixes #21491. Supersedes #21893.

Co-authored-by: bbingz <11211020+bbingz@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-25 00:16:59 -07:00
committed by GitHub
co-authored by bbingz
parent d81958f0e8
commit cae5e983a7
4 changed files with 103 additions and 7 deletions
@@ -28,6 +28,8 @@ import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter'
import { titleHasExplicitAgentIdentity } from './title-agent-identity'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty'
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
@@ -194,6 +196,27 @@ export function createTerminalKeyboardRuntime(options: RuntimeOptions) {
isLocalWindowsConptyPane() ? state.runtimePaneTitlesByTabId[tabId]?.[pane.id] : undefined
)
}
const isActivePaneNonAgentFullScreen = (): boolean => {
const manager = managerRef.current
const pane = manager?.getActivePane() ?? manager?.getPanes()[0]
if (pane?.terminal.buffer.active.type !== 'alternate') {
return false
}
const state = useAppStore.getState()
const paneKey = makePaneKey(tabId, pane.leafId)
const foreground = state.paneForegroundAgentByPaneKey[paneKey]
if (foreground?.agent && !foreground.shellForeground) {
return false
}
const hookRow = state.agentStatusByPaneKey[paneKey]
if (hookRow && hookRow.state !== 'done') {
return false
}
if (isTuiAgent(state.agentLaunchConfigByPaneKey[paneKey]?.identity?.agentType)) {
return false
}
return !titleHasExplicitAgentIdentity(state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? '')
}
const resolveShortcutEvent = (
event: Parameters<typeof resolveTerminalKeyboardShortcutAction>[0]
) =>
@@ -211,7 +234,8 @@ export function createTerminalKeyboardRuntime(options: RuntimeOptions) {
isActivePaneWindowsTerminalHost,
terminalShortcutPolicy,
hasActivePaneCtrlEnterCsiUAuthority,
getModifyOtherKeysActivePane
getModifyOtherKeysActivePane,
isActivePaneNonAgentFullScreen
)
const createCapturedInputSender = (pane: { id: number; leafId: string }, data: string) => {
const capturedTransport = paneTransportsRef.current.get(pane.id)
@@ -24,7 +24,8 @@ export function resolveTerminalKeyboardShortcutAction(
isWindowsTerminalHost: NonNullable<Parameters<typeof resolveTerminalShortcutAction>[10]>,
terminalShortcutPolicy: Parameters<typeof resolveTerminalShortcutAction>[11] = 'orca-first',
hasCtrlEnterCsiUAuthority?: Parameters<typeof resolveTerminalShortcutAction>[12],
getModifyOtherKeysActivePane?: Parameters<typeof resolveTerminalShortcutAction>[13]
getModifyOtherKeysActivePane?: Parameters<typeof resolveTerminalShortcutAction>[13],
isActivePaneNonAgentFullScreen?: Parameters<typeof resolveTerminalShortcutAction>[14]
): ReturnType<typeof resolveTerminalShortcutAction> {
return resolveTerminalShortcutAction(
event,
@@ -40,7 +41,8 @@ export function resolveTerminalKeyboardShortcutAction(
isWindowsTerminalHost,
terminalShortcutPolicy,
hasCtrlEnterCsiUAuthority,
getModifyOtherKeysActivePane
getModifyOtherKeysActivePane,
isActivePaneNonAgentFullScreen
)
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalShortcutAction } from './terminal-shortcut-policy'
const altDelete = {
key: 'Delete',
code: 'Delete',
metaKey: false,
ctrlKey: false,
altKey: true,
shiftKey: false
}
describe('Option/Alt + Forward-Delete', () => {
it('sends ESC d on macOS because Claude Code reads CSI 3;3~ as kill-to-end-of-line', () => {
expect(resolveTerminalShortcutAction(altDelete, true)).toEqual({
type: 'sendInput',
data: '\x1bd'
})
})
it('leaves Linux and Windows on xterm native CSI 3;3~', () => {
expect(resolveTerminalShortcutAction(altDelete, false)).toBeNull()
expect(resolveTerminalShortcutAction(altDelete, false, 'false', 0, true)).toBeNull()
})
it('defers to xterm kitty encoding when the app negotiated it', () => {
expect(
resolveTerminalShortcutAction(
altDelete,
true,
'false',
0,
false,
undefined,
undefined,
() => 1
)
).toBeNull()
})
it('keeps CSI 3;3~ for a non-agent full-screen app such as vim', () => {
const resolve = (nonAgentFullScreen: boolean) =>
resolveTerminalShortcutAction(
altDelete,
true,
'false',
0,
false,
undefined,
undefined,
() => 0,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
() => nonAgentFullScreen
)
expect(resolve(true)).toBeNull()
expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1bd' })
})
})
@@ -107,7 +107,9 @@ export function resolveTerminalShortcutAction(
// Why: query-only Droid/Grok consumers need CSI-u even when the live kitty flags remain inactive.
hasCtrlEnterCsiUAuthority?: () => boolean,
// Why: pi arms modifyOtherKeys when its kitty query goes unanswered; see encodeModifyOtherKeysEnter.
getModifyOtherKeysActivePane?: () => number
getModifyOtherKeysActivePane?: () => number,
// Why: vim/nano treat ESC d as Escape + d, so a non-agent full-screen app keeps xterm's CSI 3;3~.
isActivePaneNonAgentFullScreen?: () => boolean
): TerminalShortcutAction | null {
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
const matchOptions: KeybindingMatchOptions = { context: 'terminal', terminalShortcutPolicy }
@@ -270,13 +272,18 @@ export function resolveTerminalShortcutAction(
!event.ctrlKey &&
event.altKey &&
!event.shiftKey &&
event.key === 'Backspace'
(event.key === 'Backspace' ||
(isMac &&
event.key === 'Delete' &&
event.code?.startsWith('Numpad') !== true &&
isActivePaneNonAgentFullScreen?.() !== true))
) {
// Why: a kitty-protocol TUI binds the CSI 127;3u xterm emits natively; the legacy \x1b\x7f fallback would bypass it.
// Why: a kitty-protocol TUI binds xterm's native CSI 127;3u / CSI 3;3~; legacy fallbacks would bypass them.
if ((getKittyKeyboardFlagsActivePane?.() ?? 0) > 0) {
return null
}
return { type: 'sendInput', data: '\x1b\x7f' }
// Why: ⌥⌦ mirrors iTerm2's Natural Text Editing ESC d (#21491); Linux/Windows keep xterm's native CSI 3;3~.
return { type: 'sendInput', data: event.key === 'Backspace' ? '\x1b\x7f' : '\x1bd' }
}
if (