Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/renderer/components/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,7 @@ const ChatInterface: React.FC<Props> = ({
id={terminalId}
cwd={terminalCwd}
shell={providerMeta[provider].cli}
mapShiftEnterToCtrlJ
autoApprove={autoApproveEnabled}
env={undefined}
keepAlive={true}
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/MultiAgentTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ const MultiAgentTask: React.FC<Props> = ({ task }) => {
id={`${v.worktreeId}-main`}
cwd={v.path}
shell={providerMeta[v.provider].cli}
mapShiftEnterToCtrlJ
autoApprove={
Boolean(task.metadata?.autoApprove) &&
Boolean(providerMeta[v.provider]?.autoApproveFlag)
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/components/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,6 +50,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>(
keepAlive = true,
autoApprove,
initialPrompt,
mapShiftEnterToCtrlJ,
disableSnapshots = false,
onActivity,
onStartError,
Expand Down Expand Up @@ -121,6 +123,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>(
theme,
autoApprove,
initialPrompt,
mapShiftEnterToCtrlJ,
disableSnapshots,
onLinkClick: handleLinkClick,
});
Expand Down Expand Up @@ -160,6 +163,8 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>(
rows,
theme,
autoApprove,
initialPrompt,
mapShiftEnterToCtrlJ,
handleLinkClick,
onActivity,
onStartError,
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/terminal/SessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface AttachOptions {
theme: SessionTheme;
autoApprove?: boolean;
initialPrompt?: string;
mapShiftEnterToCtrlJ?: boolean;
disableSnapshots?: boolean;
onLinkClick?: (url: string) => void;
}
Expand Down Expand Up @@ -66,6 +67,7 @@ class SessionRegistry {
telemetry: null,
autoApprove: options.autoApprove,
initialPrompt: options.initialPrompt,
mapShiftEnterToCtrlJ: options.mapShiftEnterToCtrlJ,
disableSnapshots: options.disableSnapshots,
onLinkClick: options.onLinkClick,
};
Expand Down
80 changes: 52 additions & 28 deletions src/renderer/terminal/TerminalSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ export interface TerminalSessionOptions {
telemetry?: { track: (event: string, payload?: Record<string, unknown>) => void } | null;
autoApprove?: boolean;
initialPrompt?: string;
mapShiftEnterToCtrlJ?: boolean;
disableSnapshots?: boolean;
onLinkClick?: (url: string) => void;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/terminal/terminalKeybindings.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
32 changes: 32 additions & 0 deletions src/test/renderer/TerminalSessionManager.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');
});
});