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:
parent
8086f9a3f5
commit
d197c5b864
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -858,6 +858,10 @@ export function createRemoteRuntimePtyTransport(
|
|||
return null
|
||||
},
|
||||
|
||||
getRuntimeEnvironmentId() {
|
||||
return currentRuntimeEnvironmentId
|
||||
},
|
||||
|
||||
async serializeBuffer(opts) {
|
||||
if (!connected || !handle) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue