diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index f377ad3c9de..958fff9e8a3 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -279,6 +279,7 @@ describe('dashboard payload validation', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } expect( @@ -292,6 +293,7 @@ describe('dashboard payload validation', () => { { ...terminalInput, localWindowsConpty: 'true' }, { ...terminalInput, osRelease: 'x'.repeat(1_025) }, { ...terminalInput, windowsShiftEnterEncoding: 'enter' }, + { ...terminalInput, ctrlEnterCsiU: 'true' }, { ...terminalInput, kittyKeyboardAdvertised: 1 } ]) { expect( @@ -314,6 +316,7 @@ describe('dashboard payload validation', () => { hostPlatform: 'plan9', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } } diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index f1cc0b66b7c..f819392dae1 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -307,6 +307,7 @@ function isDashboardTerminalInput(value: unknown): boolean { isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) && typeof input.windowsShiftEnterEncoding === 'string' && WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) && + typeof input.ctrlEnterCsiU === 'boolean' && typeof input.kittyKeyboardAdvertised === 'boolean' ) } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index e788378a6db..7022d7e5731 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -36,6 +36,7 @@ const TERMINAL_INPUT: DashboardCardTerminalInput = { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts index 790334ace6e..e31e2279c9e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts @@ -61,6 +61,7 @@ describe('buildPreviewTerminalOptions', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -77,6 +78,7 @@ describe('buildPreviewTerminalOptions', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts index 9f3f686c3a7..4846f2ee153 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts @@ -11,6 +11,7 @@ const LOCAL_MAC: DashboardCardTerminalInput = { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } @@ -73,6 +74,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: true, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) @@ -94,6 +96,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -105,17 +108,42 @@ describe('resolvePreviewShortcutAction', () => { ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) - it('gates the Ctrl+Enter CSI-u chord on the preview pty kitty flags', () => { + it('protects local ConPTY shells while preserving trusted Ctrl+Enter consumers', () => { + const conpty = contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, + kittyKeyboardAdvertised: false + } + }) + expect( + resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), { + ...conpty, + kittyKeyboardActive: () => true + }) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + expect(resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), conpty)).toEqual({ + type: 'sendInput', + data: '\r' + }) expect( resolvePreviewShortcutAction( keydown({ key: 'Enter', ctrlKey: true }), - contextFor({ kitty: true }) + contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: true, + kittyKeyboardAdvertised: false + } + }) ) ).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', () => { diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts index e235c8d4992..1059c6b8b52 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts @@ -53,6 +53,7 @@ export function resolvePreviewShortcutAction( () => hostPlatform === 'win32', // Why: without it a terminal-first user's remapped tab.close chord — Ctrl+W // is a shell word-kill — reaches the shell in the pane but is swallowed here. - normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy) + normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy), + () => context.terminalInput?.ctrlEnterCsiU === true ) } diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts index d7aad2ce5b4..77ac13c8b15 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts @@ -42,6 +42,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) @@ -62,6 +63,37 @@ describe('resolveDashboardCardTerminalInput', () => { expect(profile.kittyKeyboardAdvertised).toBe(true) }) + it('relays trusted Ctrl+Enter authority without coupling it to Shift+Enter', () => { + const droid = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(droid.ctrlEnterCsiU).toBe(true) + + const pi = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(pi.windowsShiftEnterEncoding).toBe('csi-u') + expect(pi.ctrlEnterCsiU).toBe(false) + }) + // Why: the pty runs Linux inside WSL, so byte protocols must follow it and // not the Windows client — the pane resolves this from its own session cwd. it('treats a WSL shell override as a non-ConPTY Linux-hosted pty', () => { @@ -125,6 +157,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts index ab7fc611c75..b1b0f16c3ee 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts @@ -9,6 +9,7 @@ import { shouldDisableKittyKeyboardForTerminal } from '@/lib/pane-manager/termin import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/terminal-input-host-platform' import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from '@/components/terminal-pane/terminal-ctrl-enter' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' /** Store slices the pane's own input helpers read. */ @@ -122,6 +123,7 @@ export function resolveDashboardCardTerminalInput( localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext), ...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }), windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey), + ctrlEnterCsiU: hasCtrlEnterCsiUAuthorityForPane(state, args.paneKey), kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({ ...windowsPtyContext, // Why: launch identity is the only agent signal the board holds; it opts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts index e2d0dfca565..0e5e3d33ff8 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts @@ -110,6 +110,26 @@ describe('resolveTerminalKeyboardShortcutAction', () => { ) ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) + + it('forwards trusted Ctrl+Enter authority to the shared policy', () => { + expect( + resolveTerminalKeyboardShortcutAction( + makeKeyEvent({ key: 'Enter', ctrlKey: true }), + false, + 'false', + 0, + true, + undefined, + () => true, + () => false, + undefined, + () => 'alt-enter', + () => true, + 'orca-first', + () => true + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + }) }) describe('runTerminalSearchNavigation', () => { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6519c5f8655..b208368a40d 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -46,6 +46,7 @@ import { copyTerminalSelection } from './terminal-selection-copy' import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform' import { markTerminalFollowOutput, @@ -65,7 +66,8 @@ export function resolveTerminalKeyboardShortcutAction( layoutBaseCharacterForCode: Parameters[8], getWindowsShiftEnterEncoding: Parameters[9], isWindowsTerminalHost: NonNullable[10]>, - terminalShortcutPolicy: Parameters[11] = 'orca-first' + terminalShortcutPolicy: Parameters[11] = 'orca-first', + hasCtrlEnterCsiUAuthority?: Parameters[12] ): ReturnType { // Why: keep the host callback required at the production boundary so a // caller cannot silently fall back to client-OS byte routing. @@ -81,7 +83,8 @@ export function resolveTerminalKeyboardShortcutAction( layoutBaseCharacterForCode, getWindowsShiftEnterEncoding, isWindowsTerminalHost, - terminalShortcutPolicy + terminalShortcutPolicy, + hasCtrlEnterCsiUAuthority ) } @@ -339,7 +342,7 @@ export function useTerminalKeyboardShortcuts({ } } - // Why: foreground proof and Ctrl+Arrow translation are local ConPTY-only; + // Why: modified-Enter trust and Ctrl+Arrow translation are local ConPTY-only; // resolve lazily so session/runtime lookups stay off ordinary keystrokes. const isLocalWindowsConptyPane = (): boolean => { const manager = managerRef.current @@ -407,6 +410,22 @@ export function useTerminalKeyboardShortcuts({ return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0 } + const hasActivePaneCtrlEnterCsiUAuthority = (): boolean => { + const manager = managerRef.current + const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (!activePane) { + return false + } + const state = useAppStore.getState() + return hasCtrlEnterCsiUAuthorityForPane( + state, + makePaneKey(tabId, activePane.leafId), + isLocalWindowsConptyPane() + ? state.runtimePaneTitlesByTabId[tabId]?.[activePane.id] + : undefined + ) + } + const resolveShortcutEvent = ( event: Parameters[0] ): ReturnType => @@ -422,7 +441,8 @@ export function useTerminalKeyboardShortcuts({ getLayoutBaseCharacterForCode, getActivePaneWindowsShiftEnterEncoding, isActivePaneWindowsTerminalHost, - terminalShortcutPolicy + terminalShortcutPolicy, + hasActivePaneCtrlEnterCsiUAuthority ) const createCapturedInputSender = ( @@ -492,7 +512,8 @@ export function useTerminalKeyboardShortcuts({ e.key === 'Enter' && e.keyCode === 13 && !e.isComposing && - ((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || + (modifiedEnterChordOwner.ownsRedispatchedEnter() || + (modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || deferredNewlineSender.absorbRedispatchedEnter(e)) ) { // Chromium can drop the modifier when re-dispatching the committing Enter. @@ -819,20 +840,20 @@ export function useTerminalKeyboardShortcuts({ observedEnterKeydownTimeStamps.delete(e.code) } } - const modifiedEnterKind = getImeEnterModifier(e) - if (isWindows && modifiedEnterKind && isTerminalImeEnterKeyUp(e)) { - const chord = { kind: modifiedEnterKind, code: e.code, timeStamp: e.timeStamp } - if (modifiedEnterChordOwner.absorb(chord)) { - modifiedEnterChordOwner.release(chord) + if (isWindows && isTerminalImeEnterKeyUp(e)) { + const originatingChord = modifiedEnterChordOwner.releaseForEnterKeyUp() + if (originatingChord) { e.preventDefault() e.stopImmediatePropagation() - deferredNewlineSender.releaseRedispatchedEnter(e) + deferredNewlineSender.releaseRedispatchedEnter(e, originatingChord) return } + const modifiedEnterKind = getImeEnterModifier(e) const manager = managerRef.current const keyboardScope = keyboardScopeRef.current if ( + modifiedEnterKind && // An observed keydown makes this modifier state rollover, not a swallowed chord. !enterKeydownWasObserved && manager && @@ -864,6 +885,7 @@ export function useTerminalKeyboardShortcuts({ } } + const modifiedEnterKind = getImeEnterModifier(e) if (modifiedEnterKind) { modifiedEnterChordOwner.release({ kind: modifiedEnterKind, diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts new file mode 100644 index 00000000000..2d2b4ff6569 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' + +const PANE_KEY = 'tab:pane' + +describe('hasCtrlEnterCsiUAuthorityForPane', () => { + it('authorizes only trusted Ctrl+Enter CSI-u consumers', () => { + for (const agent of ['droid', 'grok'] as const) { + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent, routingTrusted: true, shellForeground: false } + } + }, + PANE_KEY + ) + ).toBe(true) + } + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'pi', routingTrusted: true, shellForeground: false } + } + }, + PANE_KEY + ) + ).toBe(false) + }) + + it('uses strict titles only through unrevoked trust gaps', () => { + const state = { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'droid' as const, shellForeground: false } + } + } + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, '⠋ Droid')).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, 'C:\\work\\grok-project')).toBe(false) + + for (const foreground of [ + { agent: 'grok' as const, routingRevoked: true, shellForeground: false }, + { agent: null, shellForeground: true }, + { agent: 'droid' as const, routingTrusted: true, shellForeground: true } + ]) { + expect( + hasCtrlEnterCsiUAuthorityForPane( + { paneForegroundAgentByPaneKey: { [PANE_KEY]: foreground } }, + PANE_KEY, + 'Grok' + ) + ).toBe(false) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts new file mode 100644 index 00000000000..34832ed5eda --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts @@ -0,0 +1,28 @@ +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { resolveCommittedTitleAgentType } from '../../lib/pane-agent-evidence' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +type CtrlEnterPaneState = { + paneForegroundAgentByPaneKey: Record +} + +function agentAcceptsCtrlEnterCsiU(agent: PaneForegroundAgentEntry['agent']): boolean { + return agent !== null && TUI_AGENT_CONFIG[agent].ctrlEnterEncoding === 'csi-u' +} + +/** Resolves pane-scoped authority for query-only CSI-u consumers such as Droid and Grok. */ +export function hasCtrlEnterCsiUAuthorityForPane( + state: CtrlEnterPaneState, + paneKey: string, + terminalTitle?: string +): boolean { + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + if (foreground?.shellForeground === true || foreground?.routingRevoked === true) { + return false + } + if (foreground?.routingTrusted === true) { + return agentAcceptsCtrlEnterCsiU(foreground.agent) + } + const titleAgent = terminalTitle ? resolveCommittedTitleAgentType(terminalTitle) : null + return agentAcceptsCtrlEnterCsiU(titleAgent) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts index 12f917c8018..0af8344c8f2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts @@ -180,6 +180,17 @@ describe('createTerminalImeDeferredNewlineSender', () => { expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true) }) + it('moves a credit to a balancing keyup with the redispatch timestamp', () => { + const el = document.createElement('div') + const sender = createSender() + + sender.defer(enter(10), el, vi.fn()) + sender.releaseRedispatchedEnter(enter(20), enter(10)) + + expect(sender.absorbRedispatchedEnter(enter(20))).toBe(true) + expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false) + }) + it('releases an unused credit on a later physical keyup', () => { const el = document.createElement('div') const sender = createSender() @@ -289,6 +300,15 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { expect(defer).toHaveBeenCalledTimes(1) expect(owner.absorb(chord('shift', 5037.9, 'Enter'))).toBe(true) + expect(owner.ownsRedispatchedEnter()).toBe(true) + }) + + it('releases a claimed chord when the balancing Enter keyup loses its modifier', () => { + const owner = createTerminalImeModifiedEnterChordOwner() + + expect(owner.claim(chord('shift', 10))).toBe(true) + expect(owner.releaseForEnterKeyUp()).toEqual(chord('shift', 10)) + expect(owner.ownsRedispatchedEnter()).toBe(false) }) it('does not merge different modified Enter kinds into one chord', () => { @@ -303,7 +323,7 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { const owner = createTerminalImeModifiedEnterChordOwner() expect(owner.claim(chord('ctrl', 10))).toBe(true) - owner.release(chord('ctrl', 30, 'Enter')) + expect(owner.release(chord('ctrl', 30, 'Enter'))).toEqual(chord('ctrl', 10)) expect(owner.absorb(chord('ctrl', 40, 'Enter'))).toBe(false) expect(owner.claim(chord('ctrl', 40, 'Enter'))).toBe(true) @@ -313,7 +333,7 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { const owner = createTerminalImeModifiedEnterChordOwner() expect(owner.claim(chord('shift', 10))).toBe(true) - owner.release(chord('ctrl', 20)) + expect(owner.release(chord('ctrl', 20))).toBeNull() expect(owner.absorb(chord('shift', 30))).toBe(true) owner.clear() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts index 5c79bd6419a..dfc4391d781 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts @@ -52,7 +52,10 @@ export type TerminalImeDeferredNewlineSender = { send: () => void ) => void absorbRedispatchedEnter: (enter: TerminalImeEnterIdentity) => boolean - releaseRedispatchedEnter: (enter: TerminalImeEnterIdentity) => void + releaseRedispatchedEnter: ( + enter: TerminalImeEnterIdentity, + originatingEnter?: TerminalImeEnterIdentity + ) => void clearRedispatchedEnters: () => void } @@ -67,29 +70,40 @@ export type TerminalImeModifiedEnterChord = TerminalImeEnterIdentity & { export type TerminalImeModifiedEnterChordOwner = { claim: (chord: TerminalImeModifiedEnterChord) => boolean absorb: (chord: TerminalImeModifiedEnterChord) => boolean - release: (chord: TerminalImeModifiedEnterChord) => void + ownsRedispatchedEnter: () => boolean + release: (chord: TerminalImeModifiedEnterChord) => TerminalImeModifiedEnterChord | null + releaseForEnterKeyUp: () => TerminalImeModifiedEnterChord | null clear: () => void } export function createTerminalImeModifiedEnterChordOwner(): TerminalImeModifiedEnterChordOwner { - let activeKind: TerminalImeModifiedEnterKind | null = null + let activeChord: TerminalImeModifiedEnterChord | null = null return { - claim: ({ kind }) => { - if (activeKind !== null) { + claim: (chord) => { + if (activeChord !== null) { return false } - activeKind = kind + activeChord = chord return true }, - absorb: ({ kind }) => activeKind === kind, + absorb: ({ kind }) => activeChord?.kind === kind, + ownsRedispatchedEnter: () => activeChord !== null, release: ({ kind }) => { - if (activeKind === kind) { - activeKind = null + if (activeChord?.kind === kind) { + const releasedChord = activeChord + activeChord = null + return releasedChord } + return null + }, + releaseForEnterKeyUp: () => { + const releasedChord = activeChord + activeChord = null + return releasedChord }, clear: () => { - activeKind = null + activeChord = null } } } @@ -184,7 +198,26 @@ export function createTerminalImeDeferredNewlineSender(): TerminalImeDeferredNew cleanUpIfSettled(enter, state) return true }, - releaseRedispatchedEnter: (enter) => { + releaseRedispatchedEnter: (enter, originatingEnter) => { + if (originatingEnter) { + const originatingState = statesByEnterCode + .get(originatingEnter.code) + ?.get(originatingEnter.timeStamp) + if (originatingState && originatingState.absorbCredits > 0) { + originatingState.absorbCredits -= 1 + cleanUpIfSettled(originatingEnter, originatingState) + + const statesByTimeStamp = statesByEnterCode.get(enter.code) ?? new Map() + const state = statesByTimeStamp.get(enter.timeStamp) ?? { + inFlightSends: 0, + absorbCredits: 0 + } + state.absorbCredits += 1 + statesByTimeStamp.set(enter.timeStamp, state) + statesByEnterCode.set(enter.code, statesByTimeStamp) + return + } + } if (statesByEnterCode.get(enter.code)?.has(enter.timeStamp)) { // Chromium's balancing Process-key keyup is copied from the same native event. return diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index a0f7e9a4e28..eec4a3cdd22 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -270,42 +270,50 @@ describe('resolveTerminalShortcutAction', () => { expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2) }) - 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. + it('protects local ConPTY shells without regressing query-only Ctrl+Enter consumers', () => { const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) + const isLocalWindowsConptyPane = vi.fn(() => true) + const isKittyKeyboardActivePane = vi.fn(() => false) + const hasCtrlEnterCsiUAuthority = vi.fn(() => false) const csiU = { type: 'sendInput', data: '\x1b[13;5u' } const legacyCr = { type: 'sendInput', data: '\r' } - const resolveCtrlEnter = (isMac: boolean, isWindows: boolean, kittyActive: boolean) => - resolveTerminalShortcutAction( + const resolveCtrlEnter = ( + localConpty: boolean, + kittyActive: boolean, + trustedConsumer: boolean + ) => { + isLocalWindowsConptyPane.mockReturnValue(localConpty) + isKittyKeyboardActivePane.mockReturnValue(kittyActive) + hasCtrlEnterCsiUAuthority.mockReturnValue(trustedConsumer) + return resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', ctrlKey: true }), - isMac, + false, 'false', 0, - isWindows, + true, undefined, - undefined, - () => kittyActive, + isLocalWindowsConptyPane, + isKittyKeyboardActivePane, undefined, getWindowsShiftEnterEncoding, - () => true + () => true, + 'orca-first', + hasCtrlEnterCsiUAuthority ) - 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(resolveCtrlEnter(true, false, false)).toEqual(legacyCr) + expect(resolveCtrlEnter(true, true, false)).toEqual(csiU) + expect(resolveCtrlEnter(true, false, true)).toEqual(csiU) + hasCtrlEnterCsiUAuthority.mockClear() + expect(resolveCtrlEnter(false, false, false)).toEqual(csiU) + expect(hasCtrlEnterCsiUAuthority).not.toHaveBeenCalled() expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() - // Absent tracker (surfaces that report no flags) must not send CSI-u either. + + // Missing pane-host context preserves the established Droid/Grok chord. expect( resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false) - ).toEqual(legacyCr) + ).toEqual(csiU) // Modifier combos that are NOT plain Ctrl+Enter must keep falling through. expect( 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 ec59e15189d..6ab00a13806 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -96,7 +96,7 @@ export function resolveTerminalShortcutAction( optionKeyLocation: number = 0, isWindows: boolean = false, keybindings?: KeybindingOverrides, - // Why: lazy so execution-host lookup (local native Windows ConPTY) runs only on Ctrl+Arrow, not every keystroke. + // Why: lazy so local ConPTY lookup runs only for Ctrl+Arrow and Ctrl+Enter. isLocalWindowsConptyPane?: () => boolean, // Why: gates Option-as-Alt compensation on the app's own kitty-protocol (CSI > u) opt-in, so shells keep composition. isKittyKeyboardActivePane?: () => boolean, @@ -107,7 +107,9 @@ export function resolveTerminalShortcutAction( // Why: keybindings follow the client OS, but byte protocols follow the PTY host — they differ for macOS clients on Windows runtimes. isWindowsTerminalHost: () => boolean = () => isWindows, // Why: gates the tab.close pane-close alias — under terminal-first a remapped tab.close yields to the shell (terminal.closePane, scope terminal, still closes). - terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first' + terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first', + // Why: query-only Droid/Grok consumers need CSI-u even when the live kitty flags remain inactive. + hasCtrlEnterCsiUAuthority?: () => boolean ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -195,11 +197,15 @@ export function resolveTerminalShortcutAction( !event.shiftKey && event.key === 'Enter' ) { - // 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. + const localWindowsConpty = isLocalWindowsConptyPane?.() === true + // Why: preserve query-only TUI chords elsewhere; local ConPTY shells require negotiation or trusted consumer evidence (#12329). + const canSendCsiU = + !localWindowsConpty || + isKittyKeyboardActivePane?.() === true || + hasCtrlEnterCsiUAuthority?.() === true return { type: 'sendInput', - data: isKittyKeyboardActivePane?.() === true ? '\x1b[13;5u' : '\r' + data: canSendCsiU ? '\x1b[13;5u' : '\r' } } diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 4f563886774..9367393118e 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -154,6 +154,8 @@ export type DashboardCardTerminalInput = { osRelease?: string /** Shift+Enter encoding resolved from this pane's agent evidence. */ windowsShiftEnterEncoding: 'alt-enter' | 'csi-u' + /** Trusted query-only consumer accepts Ctrl+Enter CSI-u without active flags. */ + ctrlEnterCsiU: boolean /** False withholds the kitty (CSI-u) advertisement, as ConPTY panes do. */ kittyKeyboardAdvertised: boolean } diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ffaa334fda9..e3b855a4c9e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -41,6 +41,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' + /** Ctrl+Enter encoding for agents that consume CSI-u without active kitty flags. */ + ctrlEnterEncoding?: 'csi-u' } export const TUI_AGENT_CONFIG: Record = { @@ -239,7 +241,8 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'droid', promptInjectionMode: 'argv', // Why: Droid decodes CSI-u on Windows; the legacy Esc+CR fallback reads as Enter and submits instead of newline. - windowsShiftEnterEncoding: 'csi-u' + windowsShiftEnterEncoding: 'csi-u', + ctrlEnterEncoding: 'csi-u' }, kimi: { detectCmd: 'kimi', @@ -298,7 +301,8 @@ export const TUI_AGENT_CONFIG: Record = { // Why: argv (grok takes a positional prompt) so multi-line/special-char text isn't mangled as raw PTY keystrokes. promptInjectionMode: 'argv', // Why: separator so prompts like `help`/`--version` aren't parsed as Grok CLI syntax. - argvPromptSeparator: '--' + argvPromptSeparator: '--', + ctrlEnterEncoding: 'csi-u' }, devin: { detectCmd: 'devin', diff --git a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts index 2c25306a89e..208e79a8928 100644 --- a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts +++ b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts @@ -379,9 +379,18 @@ async function assertShiftOutcome(page: Page): Promise { ]) } -// 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 { + if (process.platform !== 'win32') { + 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') + expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([]) + return + } + await expect .poll(() => readReceived(page), { timeout: 10_000, @@ -417,8 +426,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ modifiers: 2, assertOutcome: assertCtrlOutcome, expectedAfterPlainEnter: { - received: '하 하 하\r\r', - submitted: ['하 하 하', ''] + received: process.platform === 'win32' ? '하 하 하\r\r' : '하 하 하\u001b[13;5u\r', + submitted: process.platform === 'win32' ? ['하 하 하', ''] : ['하 하 하\u001b[13;5u'] } }, { diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index c8f3a800a54..10114b30cb0 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -526,31 +526,30 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') }) - test('Ctrl+Enter writes the kitty modified-enter chord only once the TUI negotiates it', async ({ + test('Ctrl+Enter protects local ConPTY shells without breaking trusted TUI chords', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) - const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForActivePanePtyId(orcaPage) - // Why: a plain shell never negotiates kitty, so CSI-u would print verbatim (#12329). - await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r') if (process.platform === 'win32') { + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r') + const paneKey = await setActivePaneForegroundAgent(orcaPage, 'droid') + try { + // Droid queries CSI-u without activating live flags; trusted process evidence preserves cue/queue. + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') + } finally { + await orcaPage.evaluate( + (key) => window.__store?.getState().clearPaneForegroundAgent(key), + paneKey + ) + } return } - // Why: the gate reads the PTY-output tracker, not xterm's renderer-local flags, - // so negotiation has to come from the application side like a real TUI's. - await execInTerminal(orcaPage, ptyId, "printf '\\033[>1u'") - await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(1) + // Preserve the established query-only Droid/Grok contract outside local ConPTY. await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') - - // Clear the shell's unconsumed CSI-u line before resetting flags in a settled - // command; otherwise its line editor can swallow the reset bytes. - await sendToTerminal(orcaPage, ptyId, '\x15\x03') - await execInTerminal(orcaPage, ptyId, "printf '\\033[=0u'") - await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(0) - await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r') }) test('plain Ctrl+C sends ETX under kitty keyboard reporting', async ({ @@ -747,8 +746,8 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf') // Ctrl+←/→ on non-mac → readline backward-word / forward-word (\eb / \ef). - // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces. - if (!isMac) { + // macOS reserves Ctrl+Arrow; Windows ConPTY leaves it to PSReadLine. + if (!isMac && process.platform !== 'win32') { await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowLeft', '\x1bb') await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowRight', '\x1bf') }