From 7287ca8ae26a80aaab5b5b76b366fbc5319ef78d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 4 Aug 2026 19:16:32 -0700 Subject: [PATCH] fix(terminal): preserve Pi Shift+Enter through trust gaps (#12618) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../terminal-pane/keyboard-handlers.ts | 67 +++++++++-------- .../terminal-pane/pty-connection.test.ts | 3 + .../terminal-pane/pty-connection.ts | 1 + .../terminal-windows-shift-enter.test.ts | 74 ++++++++++++++++++- .../terminal-windows-shift-enter.ts | 21 +++++- .../slices/pane-foreground-agent.test.ts | 8 ++ .../src/store/slices/pane-foreground-agent.ts | 3 + 7 files changed, 143 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index c1e4d1b74a5..6519c5f8655 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -339,37 +339,8 @@ export function useTerminalKeyboardShortcuts({ } } - // Why: this callback is installed once per active tab and invoked only for - // Windows Shift+Enter, keeping store work and allocations off ordinary keys. - const getActivePaneWindowsShiftEnterEncoding = () => { - const manager = managerRef.current - const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] - if (!activePane) { - return 'alt-enter' as const - } - const state = useAppStore.getState() - const paneKey = makePaneKey(tabId, activePane.leafId) - 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' - ) - } - - // Why: the active pane's live PTY session decides whether Ctrl+Arrow should - // pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef. - // Resolved lazily so session/runtime lookups stay off other keystrokes. + // Why: foreground proof 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 const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] @@ -390,6 +361,40 @@ export function useTerminalKeyboardShortcuts({ }) } + // Why: this callback is installed once per active tab and invoked only for + // Windows Shift+Enter, keeping store work and allocations off ordinary keys. + const getActivePaneWindowsShiftEnterEncoding = () => { + const manager = managerRef.current + const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (!activePane) { + return 'alt-enter' as const + } + const state = useAppStore.getState() + const paneKey = makePaneKey(tabId, activePane.leafId) + return resolveWindowsShiftEnterEncodingForPane( + state, + paneKey, + isLocalWindowsConptyPane() + ? state.runtimePaneTitlesByTabId[tabId]?.[activePane.id] + : undefined + ) + } + + // 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' + ) + } + // Why: the pane's TUI opted into kitty keyboard reporting via CSI > u; // the tracker mirrors that from PTY output so the policy can encode // Option chords the way the application negotiated. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5f5cf3a0afc..064f7dd3910 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7190,6 +7190,7 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + routingRevoked: true, shellForeground: false }) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') @@ -23218,6 +23219,7 @@ describe('connectPanePty', () => { await vi.advanceTimersByTimeAsync(1) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + routingRevoked: true, shellForeground: false }) @@ -23260,6 +23262,7 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'pi', + routingRevoked: true, shellForeground: false }) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index d0b0c2a3338..66cd4fb0aff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2183,6 +2183,7 @@ export function connectPanePty( // as a hint, but revoke bytes until one current provider confirmation lands. useAppStore.getState().setPaneForegroundAgent(cacheKey, { agent: foreground.agent, + routingRevoked: true, shellForeground: false }) visibleForegroundSamplePending = false diff --git a/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts index 19035bf1455..4fb0d4f7790 100644 --- a/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts @@ -23,7 +23,79 @@ describe('resolveWindowsShiftEnterEncoding', () => { expect(resolveWindowsShiftEnterEncoding({ launchAgentType: 'pi' })).toBe('alt-enter') }) - it('does not let hook or OSC-derived status forge Droid input routing', () => { + it('recovers Pi CSI-u from its explicit title when process trust is unavailable', () => { + const state = { + paneForegroundAgentByPaneKey: { + 'tab:pane': { agent: null, shellForeground: false } + }, + agentLaunchConfigByPaneKey: {} + } + + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:pane', '⠸ Pi')).toBe('csi-u') + expect( + resolveWindowsShiftEnterEncodingForPane( + { paneForegroundAgentByPaneKey: {}, agentLaunchConfigByPaneKey: {} }, + 'tab:pane', + 'Pi ready' + ) + ).toBe('csi-u') + }) + + it('keeps trusted process and shell evidence authoritative over titles', () => { + const state = { + paneForegroundAgentByPaneKey: { + 'tab:pane': { + agent: 'codex' as const, + routingTrusted: true, + shellForeground: false + } + }, + agentLaunchConfigByPaneKey: {} + } + + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:pane', 'Pi ready')).toBe('alt-enter') + expect( + resolveWindowsShiftEnterEncodingForPane( + { + paneForegroundAgentByPaneKey: { + 'tab:pane': { agent: null, shellForeground: true } + }, + agentLaunchConfigByPaneKey: {} + }, + 'tab:pane', + 'Pi ready' + ) + ).toBe('alt-enter') + }) + + it('does not let a stale title undo explicit routing revocation', () => { + const state = { + paneForegroundAgentByPaneKey: { + 'tab:pane': { + agent: 'pi' as const, + routingRevoked: true, + shellForeground: false + } + }, + agentLaunchConfigByPaneKey: {} + } + + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:pane', 'Pi ready')).toBe('alt-enter') + }) + + it('keeps legacy bytes for plain shell and unsupported-agent titles', () => { + const state = { + paneForegroundAgentByPaneKey: {}, + agentLaunchConfigByPaneKey: {} + } + + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:pane', 'C:\\work\\pi-project')).toBe( + 'alt-enter' + ) + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:pane', 'Codex')).toBe('alt-enter') + }) + + it('does not let hook status route bytes without a pane title or process proof', () => { const state = { paneForegroundAgentByPaneKey: {}, agentStatusByPaneKey: { diff --git a/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts index 4f55f13b943..a1939362147 100644 --- a/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts +++ b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts @@ -1,5 +1,6 @@ import type { AgentType } from '../../../../shared/agent-status-types' 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' export type WindowsShiftEnterEncoding = 'alt-enter' | 'csi-u' @@ -34,10 +35,26 @@ export function resolveWindowsShiftEnterEncoding( /** Resolves only pane-keyed evidence so a split sibling cannot inherit tab ownership. */ export function resolveWindowsShiftEnterEncodingForPane( state: WindowsShiftEnterPaneState, - paneKey: string + paneKey: string, + terminalTitle?: string ): WindowsShiftEnterEncoding { - return resolveWindowsShiftEnterEncoding({ + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + const encoding = resolveWindowsShiftEnterEncoding({ foreground: state.paneForegroundAgentByPaneKey[paneKey], launchAgentType: state.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType }) + if ( + encoding === 'csi-u' || + !terminalTitle || + foreground?.routingTrusted === true || + foreground?.routingRevoked === true || + foreground?.shellForeground === true + ) { + return encoding + } + // Why: strict pane-local titles recover Pi/Droid through process-scan gaps without overriding process or shell proof. + const titleAgent = resolveCommittedTitleAgentType(terminalTitle) + return titleAgent + ? (TUI_AGENT_CONFIG[titleAgent].windowsShiftEnterEncoding ?? 'alt-enter') + : 'alt-enter' } diff --git a/src/renderer/src/store/slices/pane-foreground-agent.test.ts b/src/renderer/src/store/slices/pane-foreground-agent.test.ts index b20239a8e34..62363eee82c 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.test.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.test.ts @@ -28,6 +28,14 @@ describe('pane foreground agent slice', () => { .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) expect(store.getState().paneForegroundAgentByPaneKey).toBe(first) + store.getState().setPaneForegroundAgent('tab-1:leaf-1', { + agent: 'aider', + routingRevoked: true, + shellForeground: false + }) + expect(store.getState().paneForegroundAgentByPaneKey).not.toBe(first) + expect(store.getState().paneForegroundAgentByPaneKey['tab-1:leaf-1']?.routingRevoked).toBe(true) + store.getState().clearPaneForegroundAgent('tab-1:leaf-1') expect(store.getState().paneForegroundAgentByPaneKey).toEqual({}) }) diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 4349b25e2ca..7adc8fea4ba 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -7,6 +7,8 @@ export type PaneForegroundAgentEntry = { agent: TuiAgent | null /** True only when fresh provider evidence is safe for input-byte routing. */ routingTrusted?: boolean + /** True after exit/input evidence revokes routing until provider confirmation. */ + routingRevoked?: boolean /** True once the foreground is proven back at the shell (OSC 133;D) — * process-grade launched-agent exit evidence, independent of titles. */ shellForeground: boolean @@ -41,6 +43,7 @@ export const createPaneForegroundAgentSlice: StateCreator< current && current.agent === entry.agent && current.routingTrusted === entry.routingTrusted && + current.routingRevoked === entry.routingRevoked && current.shellForeground === entry.shellForeground ) { return s