diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index b5656434..b1d5e204 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -880,6 +880,7 @@ const ChatInterface: React.FC = ({ id={terminalId} cwd={terminalCwd} shell={providerMeta[provider].cli} + mapShiftEnterToCtrlJ autoApprove={autoApproveEnabled} env={undefined} keepAlive={true} diff --git a/src/renderer/components/MultiAgentTask.tsx b/src/renderer/components/MultiAgentTask.tsx index 50b710fa..e8c7c21f 100644 --- a/src/renderer/components/MultiAgentTask.tsx +++ b/src/renderer/components/MultiAgentTask.tsx @@ -449,6 +449,7 @@ const MultiAgentTask: React.FC = ({ task }) => { id={`${v.worktreeId}-main`} cwd={v.path} shell={providerMeta[v.provider].cli} + mapShiftEnterToCtrlJ autoApprove={ Boolean(task.metadata?.autoApprove) && Boolean(providerMeta[v.provider]?.autoApproveFlag) diff --git a/src/renderer/components/TerminalPane.tsx b/src/renderer/components/TerminalPane.tsx index a221c245..676be0f7 100644 --- a/src/renderer/components/TerminalPane.tsx +++ b/src/renderer/components/TerminalPane.tsx @@ -26,6 +26,7 @@ type Props = { keepAlive?: boolean; autoApprove?: boolean; initialPrompt?: string; + mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; // If true, don't save/restore terminal snapshots (for non-main chats) onActivity?: () => void; onStartError?: (message: string) => void; @@ -49,6 +50,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( keepAlive = true, autoApprove, initialPrompt, + mapShiftEnterToCtrlJ, disableSnapshots = false, onActivity, onStartError, @@ -121,6 +123,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( theme, autoApprove, initialPrompt, + mapShiftEnterToCtrlJ, disableSnapshots, onLinkClick: handleLinkClick, }); @@ -160,6 +163,8 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( rows, theme, autoApprove, + initialPrompt, + mapShiftEnterToCtrlJ, handleLinkClick, onActivity, onStartError, diff --git a/src/renderer/terminal/SessionRegistry.ts b/src/renderer/terminal/SessionRegistry.ts index ecc35c5e..d298100f 100644 --- a/src/renderer/terminal/SessionRegistry.ts +++ b/src/renderer/terminal/SessionRegistry.ts @@ -16,6 +16,7 @@ interface AttachOptions { theme: SessionTheme; autoApprove?: boolean; initialPrompt?: string; + mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; onLinkClick?: (url: string) => void; } @@ -66,6 +67,7 @@ class SessionRegistry { telemetry: null, autoApprove: options.autoApprove, initialPrompt: options.initialPrompt, + mapShiftEnterToCtrlJ: options.mapShiftEnterToCtrlJ, disableSnapshots: options.disableSnapshots, onLinkClick: options.onLinkClick, }; diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 6329f1f8..b297e63c 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -9,6 +9,7 @@ import { log } from '../lib/logger'; import { TERMINAL_SNAPSHOT_VERSION, type TerminalSnapshotPayload } from '#types/terminalSnapshot'; import { PROVIDERS, PROVIDER_IDS, type ProviderId } from '@shared/providers/registry'; import { pendingInjectionManager } from '../lib/PendingInjectionManager'; +import { CTRL_J_ASCII, shouldMapShiftEnterToCtrlJ } from './terminalKeybindings'; const SNAPSHOT_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes const MAX_DATA_WINDOW_BYTES = 128 * 1024 * 1024; // 128 MB soft guardrail @@ -32,6 +33,7 @@ export interface TerminalSessionOptions { telemetry?: { track: (event: string, payload?: Record) => void } | null; autoApprove?: boolean; initialPrompt?: string; + mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; onLinkClick?: (url: string) => void; } @@ -127,40 +129,29 @@ export class TerminalSessionManager { this.applyTheme(options.theme); + // Map Shift+Enter to Ctrl+J for CLI agents only + if (options.mapShiftEnterToCtrlJ) { + this.terminal.attachCustomKeyEventHandler((event: KeyboardEvent) => { + if (shouldMapShiftEnterToCtrlJ(event)) { + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); + + // Send Ctrl+J (line feed) instead of Shift+Enter + this.handleTerminalInput(CTRL_J_ASCII); + return false; // Prevent xterm from processing the Shift+Enter + } + return true; // Let xterm handle all other keys normally + }); + } + this.metrics = new TerminalMetrics({ maxDataWindowBytes: MAX_DATA_WINDOW_BYTES, telemetry: options.telemetry ?? null, }); const inputDisposable = this.terminal.onData((data) => { - this.emitActivity(); - if (!this.disposed) { - // Filter out focus reporting sequences (CSI I = focus in, CSI O = focus out) - // These are sent by xterm.js when focus changes but shouldn't go to the PTY - const filtered = data.replace(/\x1b\[I|\x1b\[O/g, ''); - if (filtered) { - // Track command execution when Enter is pressed - const isEnterPress = filtered.includes('\r') || filtered.includes('\n'); - if (isEnterPress) { - void (async () => { - const { captureTelemetry } = await import('../lib/telemetryClient'); - captureTelemetry('terminal_command_executed'); - })(); - } - - // Check for pending injection text when Enter is pressed - const pendingText = pendingInjectionManager.getPending(); - if (pendingText && isEnterPress) { - // Append pending text to the existing input and keep the prior working behavior. - const stripped = filtered.replace(/[\r\n]+$/g, ''); - const injectedData = stripped + pendingText + '\r\r'; - window.electronAPI.ptyInput({ id: this.id, data: injectedData }); - pendingInjectionManager.markUsed(); - } else { - window.electronAPI.ptyInput({ id: this.id, data: filtered }); - } - } - } + this.handleTerminalInput(data); }); const resizeDisposable = this.terminal.onResize(({ cols, rows }) => { if (!this.disposed) { @@ -314,6 +305,39 @@ export class TerminalSessionManager { }; } + private handleTerminalInput(data: string) { + this.emitActivity(); + if (this.disposed) return; + + // Filter out focus reporting sequences (CSI I = focus in, CSI O = focus out) + // These are sent by xterm.js when focus changes but shouldn't go to the PTY + const filtered = data.replace(/\x1b\[I|\x1b\[O/g, ''); + if (!filtered) return; + + // Track command execution when Enter is pressed + const isEnterPress = filtered.includes('\r') || filtered.includes('\n'); + if (isEnterPress) { + void (async () => { + const { captureTelemetry } = await import('../lib/telemetryClient'); + captureTelemetry('terminal_command_executed'); + })(); + } + + // Check for pending injection text when Enter is pressed + const pendingText = pendingInjectionManager.getPending(); + if (pendingText && isEnterPress) { + // Append pending text to the existing input and keep the prior working behavior. + const stripped = filtered.replace(/[\r\n]+$/g, ''); + const enterSequence = filtered.includes('\r') ? '\r' : '\n'; + const injectedData = stripped + pendingText + enterSequence + enterSequence; + window.electronAPI.ptyInput({ id: this.id, data: injectedData }); + pendingInjectionManager.markUsed(); + return; + } + + window.electronAPI.ptyInput({ id: this.id, data: filtered }); + } + private applyTheme(theme: SessionTheme) { const selection = theme.base === 'light' diff --git a/src/renderer/terminal/terminalKeybindings.ts b/src/renderer/terminal/terminalKeybindings.ts new file mode 100644 index 00000000..2c0024f2 --- /dev/null +++ b/src/renderer/terminal/terminalKeybindings.ts @@ -0,0 +1,22 @@ +export type KeyEventLike = { + type: string; + key: string; + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; +}; + +// Ctrl+J sends line feed (LF) to the PTY, which CLI agents interpret as a newline +export const CTRL_J_ASCII = '\x0A'; + +export function shouldMapShiftEnterToCtrlJ(event: KeyEventLike): boolean { + return ( + event.type === 'keydown' && + event.key === 'Enter' && + event.shiftKey === true && + !event.ctrlKey && + !event.metaKey && + !event.altKey + ); +} diff --git a/src/test/renderer/TerminalSessionManager.test.ts b/src/test/renderer/TerminalSessionManager.test.ts new file mode 100644 index 00000000..4a1ab378 --- /dev/null +++ b/src/test/renderer/TerminalSessionManager.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + CTRL_J_ASCII, + shouldMapShiftEnterToCtrlJ, + type KeyEventLike, +} from '../../renderer/terminal/terminalKeybindings'; + +describe('TerminalSessionManager - Shift+Enter to Ctrl+J mapping', () => { + const makeEvent = (overrides: Partial = {}): KeyEventLike => ({ + type: 'keydown', + key: 'Enter', + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + ...overrides, + }); + + it('maps Shift+Enter to Ctrl+J only', () => { + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ shiftKey: true }))).toBe(true); + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ shiftKey: false }))).toBe(false); + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ shiftKey: true, ctrlKey: true }))).toBe(false); + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ shiftKey: true, metaKey: true }))).toBe(false); + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ shiftKey: true, altKey: true }))).toBe(false); + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ key: 'a', shiftKey: true }))).toBe(false); + expect(shouldMapShiftEnterToCtrlJ(makeEvent({ type: 'keyup', shiftKey: true }))).toBe(false); + }); + + it('uses line feed for Ctrl+J', () => { + expect(CTRL_J_ASCII).toBe('\n'); + }); +});