fix(terminal): route Shift+Enter by PTY host (#8065)

* fix(terminal): route Shift+Enter by PTY host

* fix(terminal): resolve SSH platform from execution host

* fix(terminal): preserve restored runtime input routing

* fix(terminal): route Shift+Enter by PTY host

* fix(terminal): resolve SSH platform from execution host

* fix(terminal): preserve active PTY host routing

Co-authored-by: Orca <help@stably.ai>

* test(terminal): remove superseded host-routing cases

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
BingZ 2026-07-13 08:10:26 +08:00 committed by GitHub
parent 8086f9a3f5
commit d197c5b864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 571 additions and 65 deletions

View File

@ -2036,16 +2036,17 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, SSH and paired-runtime host-platform routing including legacy runtime PTY IDs, active-PTY ownership after worktree host changes, Windows-to-WSL routing, unknown-metadata fallback, production handler composition, KKP authorization on every host, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. A live linux-arm64 Docker target additionally proved real relay deployment, SSH PTY host detection, and exact inactive/active KKP bytes. The Electron byte test runs only on Windows; live Windows evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7620",
"https://github.com/stablyai/orca/pull/7668"
],
"invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.",
"oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
"invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.",
"oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts"
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts"
],
"testFiles": [
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
@ -2060,9 +2061,12 @@
"src/main/providers/local-pty-provider.test.ts",
"src/main/providers/windows-conpty-process-membership.test.ts",
"src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
"src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts",
"src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
"src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
"src/renderer/src/store/slices/store-cascades.test.ts",
@ -2132,12 +2136,33 @@
"one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell"
]
},
{
"file": "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
"assertions": [
"the production keyboard-policy boundary requires active-host routing and composes macOS Shift+Enter with Windows PTY bytes"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"assertions": [
"a legacy remote PTY id retains the transport's captured runtime environment identity"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts",
"assertions": [
"SSH terminals use remote-platform metadata and execution-host identity when the active transport has not captured a connection id",
"encoded and legacy active remote-runtime PTY identities plus a live local PTY session outrank later worktree host changes",
"a live WSL session normalizes to a Linux terminal host",
"missing SSH/runtime platform metadata and local terminals fall back to the client platform"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
"assertions": [
"Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u",
"SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability",
"non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks"
"Windows and non-Windows PTYs reached cross-OS use CSI-u only with active Kitty keyboard protocol unless trusted Windows Droid evidence independently authorizes it",
"unrelated keys invoke neither host, agent, nor protocol callbacks"
]
},
{
@ -2238,20 +2263,20 @@
},
"performanceBudget": {
"required": true,
"evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
"evidence": "Callback-count tests prove unrelated ordinary keys invoke neither host, agent, nor Kitty-protocol resolution. Host and agent callbacks are created once per keyboard effect, so printable input adds no per-key allocations or store reads. Shift+Enter performs bounded synchronous active-transport and indexed store lookups, plus at most one pane-keyed protocol-mode lookup when trusted Windows Droid evidence does not already authorize CSI-u, with no IPC, RPC, subprocess, polling, listener, or timer. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
},
"promotionCriteria": [
"Run the exact PTY-byte Electron oracle on Windows without silent platform skips.",
"Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.",
"Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic host-routing assertions for SSH, WSL, and remote runtime.",
"Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage."
],
"knownGaps": [
"No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.",
"Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.",
"No Windows runner or live Windows PTY evidence is attached; the Electron byte test skips on macOS and Linux, while a live Linux SSH PTY covers only the remote Linux host path.",
"Droid agent-specific CSI-u authorization is available only from trusted pane evidence; without it, Windows and non-Windows PTY hosts depend on the application's Kitty keyboard protocol state.",
"The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.",
"If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.",
"Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.",
"No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.",
"No live daemon, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected; SSH is covered only by a macOS-client-to-Linux-host relay run.",
"A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs."
],
"demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak."

View File

@ -4,6 +4,7 @@ import { FIND_QUERY_MAX_BYTES } from '@/lib/find-query-bounds'
import {
matchFileSearchShortcut,
matchSearchNavigate,
resolveTerminalKeyboardShortcutAction,
runTerminalSearchNavigation
} from './keyboard-handlers'
@ -91,6 +92,26 @@ describe('matchSearchNavigate', () => {
})
})
describe('resolveTerminalKeyboardShortcutAction', () => {
it('routes macOS Shift+Enter with the active Windows PTY host bytes', () => {
expect(
resolveTerminalKeyboardShortcutAction(
makeKeyEvent({ key: 'Enter', shiftKey: true }),
true,
'false',
0,
false,
undefined,
undefined,
undefined,
undefined,
() => 'alt-enter',
() => true
)
).toEqual({ type: 'sendInput', data: '\x1b\r' })
})
})
describe('runTerminalSearchNavigation', () => {
const searchState = { query: 'hello', caseSensitive: true, regex: false }

View File

@ -31,12 +31,43 @@ import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import {
markTerminalFollowOutput,
markTerminalPinnedViewport,
syncTerminalScrollIntentFromViewport
} from '@/lib/pane-manager/terminal-scroll-intent'
export function resolveTerminalKeyboardShortcutAction(
event: Parameters<typeof resolveTerminalShortcutAction>[0],
isMac: Parameters<typeof resolveTerminalShortcutAction>[1],
macOptionAsAlt: Parameters<typeof resolveTerminalShortcutAction>[2],
optionKeyLocation: Parameters<typeof resolveTerminalShortcutAction>[3],
isWindows: Parameters<typeof resolveTerminalShortcutAction>[4],
keybindings: Parameters<typeof resolveTerminalShortcutAction>[5],
isLocalWindowsConptyPane: Parameters<typeof resolveTerminalShortcutAction>[6],
isKittyKeyboardActivePane: Parameters<typeof resolveTerminalShortcutAction>[7],
layoutBaseCharacterForCode: Parameters<typeof resolveTerminalShortcutAction>[8],
getWindowsShiftEnterEncoding: Parameters<typeof resolveTerminalShortcutAction>[9],
isWindowsTerminalHost: NonNullable<Parameters<typeof resolveTerminalShortcutAction>[10]>
): 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.
return resolveTerminalShortcutAction(
event,
isMac,
macOptionAsAlt,
optionKeyLocation,
isWindows,
keybindings,
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
layoutBaseCharacterForCode,
getWindowsShiftEnterEncoding,
isWindowsTerminalHost
)
}
export function recordKeyboardCreatedTerminalPaneSplit(
createdPane: unknown,
args: {
@ -253,6 +284,21 @@ export function useTerminalKeyboardShortcuts({
return resolveWindowsShiftEnterEncodingForPane(state, paneKey)
}
// Why: host metadata is live and can hydrate after the terminal mounts;
// resolve it only when Shift+Enter needs to choose a byte protocol.
const isActivePaneWindowsTerminalHost = (): boolean => {
const manager = managerRef.current
const activePane = manager?.getActivePane() ?? manager?.getPanes()[0]
return (
resolveTerminalInputHostPlatform({
clientPlatform: shortcutPlatform,
state: useAppStore.getState(),
worktreeId,
transport: activePane ? (paneTransportsRef.current.get(activePane.id) ?? null) : null
}) === 'win32'
)
}
const onKeyDown = (e: KeyboardEvent): void => {
const manager = managerRef.current
if (!manager) {
@ -335,7 +381,7 @@ export function useTerminalKeyboardShortcuts({
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
}
const action = resolveTerminalShortcutAction(
const action = resolveTerminalKeyboardShortcutAction(
e,
isMac,
macOptionAsAltRef.current,
@ -345,7 +391,8 @@ export function useTerminalKeyboardShortcuts({
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
getLayoutBaseCharacterForCode,
getActivePaneWindowsShiftEnterEncoding
getActivePaneWindowsShiftEnterEncoding,
isActivePaneWindowsTerminalHost
)
if (!action) {
return

View File

@ -32,7 +32,10 @@ export type PtyBufferSnapshot = {
pendingEscapeTailAnsi?: string
}
export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string }
export type LocalPtySessionMetadata = {
cwd?: string
shellOverride?: string
}
export type PtyConnectResult = {
id: string
@ -116,6 +119,9 @@ export type PtyTransport = {
isConnected: () => boolean
getPtyId: () => string | null
getConnectionId?: () => string | null | undefined
/** The runtime captured by this transport; legacy remote PTY ids do not
* encode their owner, and current worktree settings may have changed. */
getRuntimeEnvironmentId?: () => string | null
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
/** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a
* model-restore marker reports dropped bytes a carry spanning the gap

View File

@ -238,7 +238,18 @@ describe('createIpcPtyTransport', () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const localTransport = createIpcPtyTransport({
cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo',
shellOverride: 'wsl.exe'
shellOverride: 'wsl.exe',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo',
distro: 'Ubuntu-24.04',
reason: 'project-override',
cacheKey: 'repo:wsl'
}
}
})
const sshTransport = createIpcPtyTransport({
connectionId: 'ssh-1',
@ -253,6 +264,51 @@ describe('createIpcPtyTransport', () => {
expect(sshTransport.getLocalSessionMetadata?.()).toBeNull()
})
it('keeps captured Windows and WSL metadata when existing PTYs reattach', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const currentWslForWindowsPty = createIpcPtyTransport({
cwd: 'C:\\repo',
shellOverride: 'pwsh.exe',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo',
distro: 'Ubuntu-24.04',
reason: 'project-override',
cacheKey: 'repo:wsl'
}
}
})
const currentWindowsForWslPty = createIpcPtyTransport({
cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo',
shellOverride: 'wsl.exe',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'windows-host',
hostPlatform: 'win32',
projectId: 'repo',
reason: 'project-override',
cacheKey: 'repo:windows'
}
}
})
currentWslForWindowsPty.attach({ existingPtyId: 'windows-pty', callbacks: {} })
currentWindowsForWslPty.attach({ existingPtyId: 'wsl-pty', callbacks: {} })
expect(currentWslForWindowsPty.getLocalSessionMetadata?.()).toEqual({
cwd: 'C:\\repo',
shellOverride: 'pwsh.exe'
})
expect(currentWindowsForWslPty.getLocalSessionMetadata?.()).toEqual({
cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo',
shellOverride: 'wsl.exe'
})
})
it('sends the missing-cwd fallback flag only for local IPC spawns', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>

View File

@ -1013,8 +1013,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
if (connectionId) {
return null
}
// Why: paste/runtime diagnostics must follow the launched PTY session,
// not later project setting changes.
// Why: input routing and diagnostics must follow the launched PTY
// session, not later project setting changes.
return {
...(cwd ? { cwd } : {}),
...(shellOverride ? { shellOverride } : {})

View File

@ -168,6 +168,7 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onError).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')
expect(transport.getRuntimeEnvironmentId?.()).toBe('env-1')
await vi.waitFor(() =>
expect(latestSubscribePayload().capabilities).toEqual({
ackOutput: 1,

View File

@ -858,6 +858,10 @@ export function createRemoteRuntimePtyTransport(
return null
},
getRuntimeEnvironmentId() {
return currentRuntimeEnvironmentId
},
async serializeBuffer(opts) {
if (!connected || !handle) {
return null

View File

@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
function state(overrides: Partial<AppState> = {}): AppState {
return {
repos: [],
worktreesByRepo: {},
folderWorkspaces: [],
projectGroups: [],
settings: { activeRuntimeEnvironmentId: null },
sshConnectionStates: new Map(),
runtimeStatusByEnvironmentId: new Map(),
restoredRuntimeHostIdByWorkspaceSessionKey: {},
...overrides
} as AppState
}
describe('resolveTerminalInputHostPlatform', () => {
it('uses a paired runtime host platform instead of the macOS client', () => {
const worktreeId = 'repo::C:\\repo'
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({
repos: [
{
id: 'repo',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:windows-box'
}
],
runtimeStatusByEnvironmentId: new Map([
[
'windows-box',
{
status: { hostPlatform: 'win32' }
} as AppState['runtimeStatusByEnvironmentId'] extends Map<string, infer T> ? T : never
]
])
}),
worktreeId,
transport: null
})
).toBe('win32')
})
it('uses the active remote runtime PTY owner after worktree ownership changes', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({
repos: [
{
id: 'repo',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:linux-box'
}
],
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } } as never],
['linux-box', { status: { hostPlatform: 'linux' } } as never]
])
}),
worktreeId: 'repo::C:\\repo',
transport: {
getConnectionId: () => null,
getPtyId: () => 'remote:windows-box@@terminal-1'
}
})
).toBe('win32')
})
it('uses captured runtime ownership for a legacy remote PTY id', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } } as never]
])
}),
worktreeId: 'repo::/repo',
transport: {
getConnectionId: () => null,
getPtyId: () => 'remote:terminal-1',
getRuntimeEnvironmentId: () => 'windows-box'
}
})
).toBe('win32')
})
it('keeps a live local PTY on the client after worktree ownership changes', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({
repos: [
{
id: 'repo',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:windows-box'
}
],
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } } as never]
])
}),
worktreeId: 'repo::/repo',
transport: {
getConnectionId: () => null,
getPtyId: () => 'local-pty-1',
getLocalSessionMetadata: () => ({ cwd: '/repo' })
}
})
).toBe('darwin')
})
it('normalizes a live WSL session to a Linux terminal host', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'win32',
state: state(),
worktreeId: 'repo::C:\\repo',
transport: {
getConnectionId: () => null,
getPtyId: () => 'local-pty-1',
getLocalSessionMetadata: () => ({
shellOverride: ' "C:\\Windows\\System32\\wsl.exe" -d Ubuntu-24.04'
})
}
})
).toBe('linux')
})
it('keeps a live native Windows session after the worktree switches to WSL', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'win32',
state: state({
repos: [
{
id: 'repo',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:linux-box'
}
],
runtimeStatusByEnvironmentId: new Map([
['linux-box', { status: { hostPlatform: 'linux' } } as never]
])
}),
worktreeId: 'repo::C:\\repo',
transport: {
getConnectionId: () => null,
getPtyId: () => 'windows-pty-1',
getLocalSessionMetadata: () => ({ cwd: 'C:\\repo', shellOverride: 'pwsh.exe' })
}
})
).toBe('win32')
})
it('uses SSH remote platform metadata', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({
sshConnectionStates: new Map([['ssh-win', { remotePlatform: 'win32' } as never]])
}),
worktreeId: 'repo::C:\\repo',
transport: { getConnectionId: () => 'ssh-win' }
})
).toBe('win32')
})
it('falls back to the client when SSH platform metadata is unavailable', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({ sshConnectionStates: new Map([['ssh-unknown', {} as never]]) }),
worktreeId: 'repo::/repo',
transport: { getConnectionId: () => 'ssh-unknown' }
})
).toBe('darwin')
})
it('falls back to the client when runtime platform metadata is unavailable', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state(),
worktreeId: 'repo::/repo',
transport: {
getConnectionId: () => null,
getPtyId: () => 'remote:windows-box@@terminal-1',
getRuntimeEnvironmentId: () => 'windows-box'
}
})
).toBe('darwin')
})
it('uses the SSH execution host when the transport has no connection id', () => {
const worktreeId = 'repo::C:\\repo'
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state({
repos: [
{
id: 'repo',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'ssh:ssh-win'
}
],
sshConnectionStates: new Map([['ssh-win', { remotePlatform: 'win32' } as never]])
}),
worktreeId,
transport: { getConnectionId: () => null }
})
).toBe('win32')
})
it('keeps the client platform for local terminals', () => {
expect(
resolveTerminalInputHostPlatform({
clientPlatform: 'darwin',
state: state(),
worktreeId: 'repo::/repo',
transport: null
})
).toBe('darwin')
})
})

View File

@ -0,0 +1,73 @@
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import type { AppState } from '@/store/types'
import type { PtyTransport } from './pty-transport-types'
import { isWslShellOverride } from './terminal-paste-runtime'
type TerminalInputHostPlatformState = Pick<
AppState,
| 'repos'
| 'worktreesByRepo'
| 'folderWorkspaces'
| 'projectGroups'
| 'settings'
| 'sshConnectionStates'
| 'runtimeStatusByEnvironmentId'
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
>
export function resolveTerminalInputHostPlatform(args: {
clientPlatform: NodeJS.Platform
state: TerminalInputHostPlatformState
worktreeId: string
transport:
| (Pick<PtyTransport, 'getConnectionId'> &
Partial<
Pick<PtyTransport, 'getPtyId' | 'getRuntimeEnvironmentId' | 'getLocalSessionMetadata'>
>)
| null
}): NodeJS.Platform {
const transportConnectionId = args.transport?.getConnectionId?.()
const connectionId =
transportConnectionId === undefined
? getConnectionIdFromState(args.state, args.worktreeId)
: transportConnectionId
if (connectionId) {
return args.state.sshConnectionStates.get(connectionId)?.remotePlatform ?? args.clientPlatform
}
// Why: a running pane keeps its spawn-time runtime even if the worktree's
// selected host changes later, so the live PTY identity is authoritative.
const ptyId = args.transport?.getPtyId?.() ?? null
const runtimeEnvironmentId =
args.transport?.getRuntimeEnvironmentId?.() ??
(ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null)
if (runtimeEnvironmentId) {
return (
args.state.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)?.status?.hostPlatform ??
args.clientPlatform
)
}
const localSessionMetadata = args.transport?.getLocalSessionMetadata?.()
if (ptyId !== null && localSessionMetadata != null) {
const isWslSession =
isWslUncPath(localSessionMetadata.cwd ?? '') ||
isWslShellOverride(localSessionMetadata.shellOverride)
return args.clientPlatform === 'win32' && isWslSession ? 'linux' : args.clientPlatform
}
const host = parseExecutionHostId(getExecutionHostIdForWorktree(args.state, args.worktreeId))
if (host?.kind === 'ssh') {
return args.state.sshConnectionStates.get(host.targetId)?.remotePlatform ?? args.clientPlatform
}
if (host?.kind === 'runtime') {
return (
args.state.runtimeStatusByEnvironmentId.get(host.environmentId)?.status?.hostPlatform ??
args.clientPlatform
)
}
return args.clientPlatform
}

View File

@ -85,7 +85,7 @@ function resolveWslRuntimeKey(
return null
}
function isWslShellOverride(shellOverride: string | null | undefined): boolean {
export function isWslShellOverride(shellOverride: string | null | undefined): boolean {
const executable = getShellOverrideExecutableToken(shellOverride)
const segmentStart = getShellOverridePathSegmentStart(executable)
const name = executable.slice(segmentStart).toLowerCase()

View File

@ -139,28 +139,24 @@ describe('resolveTerminalShortcutAction', () => {
).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
})
it('preserves the Windows fallback for SSH, WSL, and remote panes', () => {
// Why: a Windows client cannot safely infer a remote peer's decoder, so the
// local Droid exception must not broaden main's existing remote behavior.
const isLocalWindowsConptyPane = vi.fn(() => false)
it('uses CSI-u for a non-Windows PTY reached from Windows only while Kitty is active', () => {
const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const)
for (let index = 0; index < 2; index += 1) {
expect(
resolveTerminalShortcutAction(
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
false,
'false',
0,
true,
undefined,
isLocalWindowsConptyPane,
undefined,
undefined,
getWindowsShiftEnterEncoding
)
).toEqual({ type: 'sendInput', data: '\x1b\r' })
}
expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2)
const resolve = (kittyActive: boolean) =>
resolveTerminalShortcutAction(
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
false,
'false',
0,
true,
undefined,
undefined,
() => kittyActive,
undefined,
getWindowsShiftEnterEncoding,
() => false
)
expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' })
expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled()
})
@ -184,9 +180,11 @@ describe('resolveTerminalShortcutAction', () => {
}
})
it('keeps ConPTY and agent lookups off unrelated keystrokes', () => {
it('keeps host and agent lookups off unrelated keystrokes', () => {
const isLocalWindowsConptyPane = vi.fn(() => true)
const isKittyKeyboardActivePane = vi.fn(() => true)
const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const)
const isWindowsTerminalHost = vi.fn(() => true)
expect(
resolveTerminalShortcutAction(
@ -197,13 +195,16 @@ describe('resolveTerminalShortcutAction', () => {
true,
undefined,
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
undefined,
undefined,
getWindowsShiftEnterEncoding
getWindowsShiftEnterEncoding,
isWindowsTerminalHost
)
).toBeNull()
expect(isLocalWindowsConptyPane).not.toHaveBeenCalled()
expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled()
expect(isWindowsTerminalHost).not.toHaveBeenCalled()
expect(isKittyKeyboardActivePane).not.toHaveBeenCalled()
expect(
resolveTerminalShortcutAction(
@ -214,15 +215,18 @@ describe('resolveTerminalShortcutAction', () => {
true,
undefined,
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
undefined,
undefined,
getWindowsShiftEnterEncoding
getWindowsShiftEnterEncoding,
isWindowsTerminalHost
)
).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(1)
expect(isLocalWindowsConptyPane).not.toHaveBeenCalled()
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1)
expect(isWindowsTerminalHost).toHaveBeenCalledTimes(1)
expect(isKittyKeyboardActivePane).not.toHaveBeenCalled()
isLocalWindowsConptyPane.mockReturnValue(false)
isWindowsTerminalHost.mockReturnValue(false)
expect(
resolveTerminalShortcutAction(
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
@ -232,13 +236,37 @@ describe('resolveTerminalShortcutAction', () => {
true,
undefined,
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
undefined,
undefined,
getWindowsShiftEnterEncoding
getWindowsShiftEnterEncoding,
isWindowsTerminalHost
)
).toEqual({ type: 'sendInput', data: '\x1b\r' })
expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2)
).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
expect(isLocalWindowsConptyPane).not.toHaveBeenCalled()
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1)
expect(isWindowsTerminalHost).toHaveBeenCalledTimes(2)
expect(isKittyKeyboardActivePane).toHaveBeenCalledTimes(1)
})
it('honors Kitty negotiation for a Windows PTY reached from macOS', () => {
const getWindowsShiftEnterEncoding = vi.fn(() => 'alt-enter' as const)
const resolve = (kittyActive: boolean) =>
resolveTerminalShortcutAction(
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
true,
'false',
0,
false,
undefined,
undefined,
() => kittyActive,
undefined,
getWindowsShiftEnterEncoding,
() => true
)
expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' })
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2)
})
it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => {

View File

@ -76,8 +76,8 @@ export function resolveTerminalShortcutAction(
isWindows: boolean = false,
keybindings?: KeybindingOverrides,
// Why: lazily reports whether the active pane is a local native Windows
// ConPTY. Only consulted for Shift+Enter and Ctrl+Arrow, so execution-host
// lookup stays off every other keystroke.
// ConPTY. Only consulted for Ctrl+Arrow, so execution-host lookup stays off
// every other keystroke.
isLocalWindowsConptyPane?: () => boolean,
// Why: lazily reports whether the active pane's application has enabled the
// kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation
@ -90,7 +90,10 @@ export function resolveTerminalShortcutAction(
layoutBaseCharacterForCode?: (code: string) => string | undefined,
// Why: lazily resolves the active pane's Windows encoding. Only consulted for
// Shift+Enter so agent-state lookup stays off every other keystroke.
getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding
getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding,
// Why: keybindings follow the client OS, but terminal byte protocols follow
// the PTY host. They differ for macOS clients attached to Windows runtimes.
isWindowsTerminalHost: () => boolean = () => isWindows
): TerminalShortcutAction | null {
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
if (!event.repeat) {
@ -150,18 +153,13 @@ export function resolveTerminalShortcutAction(
event.shiftKey &&
event.key === 'Enter'
) {
// Why: Droid needs CSI-u but Codex needs Esc+CR; preserve legacy bytes for
// SSH/WSL/remote peers that cannot be safely classified from this client.
const useLocalWindowsCapability = isWindows && isLocalWindowsConptyPane?.() !== false
const encoding = useLocalWindowsCapability
? (getWindowsShiftEnterEncoding?.() ?? 'alt-enter')
: isWindows
? 'alt-enter'
: 'csi-u'
// Why: CSI-u is application input, not a universal terminal sequence. Off
// Windows, only send it while the pane's application has KKP active.
const canSendCsiU =
encoding === 'csi-u' && (useLocalWindowsCapability || isKittyKeyboardActivePane?.() === true)
// Why: negotiated KKP is authoritative on every host; trusted pane
// evidence additionally preserves Droid's Windows encoding without KKP.
const windowsHost = isWindowsTerminalHost()
const hasTrustedWindowsCsiU = windowsHost && getWindowsShiftEnterEncoding?.() === 'csi-u'
// Why: CSI-u is application input, not a universal terminal sequence.
// Without trusted Windows agent evidence, require active KKP negotiation.
const canSendCsiU = hasTrustedWindowsCsiU || isKittyKeyboardActivePane?.() === true
return { type: 'sendInput', data: canSendCsiU ? '\x1b[13;2u' : '\x1b\r' }
}