fix(terminal): preserve trusted Ctrl+Enter routing

This commit is contained in:
OrcaWin
2026-08-06 19:36:51 -07:00
parent 699d4cbd49
commit f0306d9ffc
20 changed files with 358 additions and 81 deletions
@@ -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
}
}
@@ -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'
)
}
@@ -36,6 +36,7 @@ const TERMINAL_INPUT: DashboardCardTerminalInput = {
localWindowsConpty: true,
osRelease: '10.0.22631',
windowsShiftEnterEncoding: 'csi-u',
ctrlEnterCsiU: false,
kittyKeyboardAdvertised: false
}
@@ -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
}
})
@@ -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', () => {
@@ -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
)
}
@@ -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
})
})
@@ -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
@@ -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', () => {
@@ -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<typeof resolveTerminalShortcutAction>[8],
getWindowsShiftEnterEncoding: Parameters<typeof resolveTerminalShortcutAction>[9],
isWindowsTerminalHost: NonNullable<Parameters<typeof resolveTerminalShortcutAction>[10]>,
terminalShortcutPolicy: Parameters<typeof resolveTerminalShortcutAction>[11] = 'orca-first'
terminalShortcutPolicy: Parameters<typeof resolveTerminalShortcutAction>[11] = 'orca-first',
hasCtrlEnterCsiUAuthority?: Parameters<typeof resolveTerminalShortcutAction>[12]
): ReturnType<typeof resolveTerminalShortcutAction> {
// 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<typeof resolveTerminalKeyboardShortcutAction>[0]
): ReturnType<typeof resolveTerminalKeyboardShortcutAction> =>
@@ -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,
@@ -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)
}
})
})
@@ -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<string, PaneForegroundAgentEntry | undefined>
}
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)
}
@@ -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()
@@ -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
@@ -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(
@@ -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'
}
}
+2
View File
@@ -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
}
+6 -2
View File
@@ -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<TuiAgent, TuiAgentConfig> = {
@@ -239,7 +241,8 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
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<TuiAgent, TuiAgentConfig> = {
// 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',
@@ -379,9 +379,18 @@ 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> {
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']
}
},
{
+16 -17
View File
@@ -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')
}