diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index ff36cd217..906ff6635 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -910,6 +910,7 @@ const ChatInterface: React.FC = ({ autoApprove={autoApproveEnabled} env={undefined} keepAlive={true} + mapShiftEnterToCtrlJ disableSnapshots={false} onActivity={() => { try { diff --git a/src/renderer/components/MultiAgentTask.tsx b/src/renderer/components/MultiAgentTask.tsx index 4dcf567c1..26da9278f 100644 --- a/src/renderer/components/MultiAgentTask.tsx +++ b/src/renderer/components/MultiAgentTask.tsx @@ -493,6 +493,7 @@ const MultiAgentTask: React.FC = ({ task }) => { : undefined } keepAlive + mapShiftEnterToCtrlJ variant={isDark ? 'dark' : 'light'} themeOverride={ v.agent === 'mistral' diff --git a/src/renderer/components/TerminalPane.tsx b/src/renderer/components/TerminalPane.tsx index 264fec38e..a049f0664 100644 --- a/src/renderer/components/TerminalPane.tsx +++ b/src/renderer/components/TerminalPane.tsx @@ -27,6 +27,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; @@ -51,6 +52,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( keepAlive = true, autoApprove, initialPrompt, + mapShiftEnterToCtrlJ, disableSnapshots = false, onActivity, onStartError, @@ -124,6 +126,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( theme, autoApprove, initialPrompt, + mapShiftEnterToCtrlJ, disableSnapshots, onLinkClick: handleLinkClick, }); @@ -164,6 +167,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 66c2ba40e..2ad26a613 100644 --- a/src/renderer/terminal/SessionRegistry.ts +++ b/src/renderer/terminal/SessionRegistry.ts @@ -17,6 +17,7 @@ interface AttachOptions { theme: SessionTheme; autoApprove?: boolean; initialPrompt?: string; + mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; onLinkClick?: (url: string) => void; } @@ -68,6 +69,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 cc3f46359..cc6cb2f90 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 { pendingInjectionManager } from '../lib/PendingInjectionManager'; import { getProvider, type ProviderId } from '@shared/providers/registry'; +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 @@ -33,6 +34,7 @@ export interface TerminalSessionOptions { telemetry?: { track: (event: string, payload?: Record) => void } | null; autoApprove?: boolean; initialPrompt?: string; + mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; onLinkClick?: (url: string) => void; } @@ -134,40 +136,30 @@ 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 + // Pass true to skip injection handling - this is a newline insert, not a submit + this.handleTerminalInput(CTRL_J_ASCII, true); + 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) { @@ -321,6 +313,39 @@ export class TerminalSessionManager { }; } + private handleTerminalInput(data: string, isNewlineInsert: boolean = false) { + 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 (but not for newline inserts) + const isEnterPress = filtered.includes('\r') || filtered.includes('\n'); + if (isEnterPress && !isNewlineInsert) { + void (async () => { + const { captureTelemetry } = await import('../lib/telemetryClient'); + captureTelemetry('terminal_command_executed'); + })(); + } + + // Check for pending injection text when Enter is pressed (but not for newline inserts) + const pendingText = pendingInjectionManager.getPending(); + if (pendingText && isEnterPress && !isNewlineInsert) { + // 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 000000000..2c0024f27 --- /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/terminalKeybindings.test.ts b/src/test/renderer/terminalKeybindings.test.ts new file mode 100644 index 000000000..4a1ab3785 --- /dev/null +++ b/src/test/renderer/terminalKeybindings.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'); + }); +});