From cae5e983a7240cbad9b0aed088d6653dafaa647a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:16:59 -0700 Subject: [PATCH] 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> --- .../terminal-keyboard-runtime.ts | 26 +++++++- .../terminal-keyboard-shortcut-matching.ts | 6 +- .../terminal-shortcut-forward-delete.test.ts | 63 +++++++++++++++++++ .../terminal-pane/terminal-shortcut-policy.ts | 15 +++-- 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-shortcut-forward-delete.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts index 9762c1d35bc..df017dde102 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts @@ -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[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) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-shortcut-matching.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-shortcut-matching.ts index 1c868f6715f..2b0bcb23a1f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-shortcut-matching.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-shortcut-matching.ts @@ -24,7 +24,8 @@ export function resolveTerminalKeyboardShortcutAction( isWindowsTerminalHost: NonNullable[10]>, terminalShortcutPolicy: Parameters[11] = 'orca-first', hasCtrlEnterCsiUAuthority?: Parameters[12], - getModifyOtherKeysActivePane?: Parameters[13] + getModifyOtherKeysActivePane?: Parameters[13], + isActivePaneNonAgentFullScreen?: Parameters[14] ): ReturnType { return resolveTerminalShortcutAction( event, @@ -40,7 +41,8 @@ export function resolveTerminalKeyboardShortcutAction( isWindowsTerminalHost, terminalShortcutPolicy, hasCtrlEnterCsiUAuthority, - getModifyOtherKeysActivePane + getModifyOtherKeysActivePane, + isActivePaneNonAgentFullScreen ) } diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-forward-delete.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-forward-delete.test.ts new file mode 100644 index 00000000000..bc70afc021b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-forward-delete.test.ts @@ -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' }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 1398a8cc643..be798cceed4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -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 (