mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(terminal): preserve trusted Ctrl+Enter routing
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user