Skip to content
Closed
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 docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Wall starts in `command` mode by default. Embedders may pass `initialMode="passt
- All keyboard input routes to the active session's xterm.js instance
- Only the mode-exit gesture (LCmd → RCmd) is intercepted
- In the VS Code host, selected workbench chords are mirrored: xterm still processes the key, and Dormouse also asks the extension host to run the matching VS Code command. See [the VS Code host spec](vscode.md) for the allowlist.
- On Windows, `Shift+Enter` is normalized before xterm's default Enter handling by injecting LF (`\x0a`) through xterm's user-input path. This mirrors the `Ctrl+J`/terminal `sendInput` workaround used for terminal TUIs such as Codex, where plain `Enter` submits and LF inserts a newline.
- Selection overlay shows 2px solid border with glow
- Terminal has DOM focus

Expand Down
6 changes: 6 additions & 0 deletions lib/src/lib/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const PLATFORM_STRING: string = (() => {
*/
export const IS_MAC: boolean = /Mac|iPhone|iPad/i.test(PLATFORM_STRING);

/**
* True when running on Windows. Used for keyboard behavior that Windows
* terminals commonly normalize differently from macOS terminals.
*/
export const IS_WINDOWS: boolean = /Win/i.test(PLATFORM_STRING);

let adapter: PlatformAdapter | null = null;

/** Set an externally-created platform adapter (e.g. TauriAdapter from standalone). */
Expand Down
47 changes: 47 additions & 0 deletions lib/src/lib/terminal-keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
SHIFT_ENTER_NEWLINE_INPUT,
shiftEnterInputForEvent,
shouldHandleWindowsShiftEnter,
} from './terminal-keyboard';

function keydown(init: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
altKey: false,
ctrlKey: false,
isComposing: false,
key: 'Enter',
metaKey: false,
shiftKey: true,
type: 'keydown',
...init,
} as KeyboardEvent;
}

describe('terminal keyboard normalization', () => {
it('uses LF for the Shift+Enter newline override', () => {
expect(SHIFT_ENTER_NEWLINE_INPUT).toBe('\n');
});

it('matches plain Shift+Enter on Windows', () => {
expect(shouldHandleWindowsShiftEnter(keydown(), { isWindows: true })).toBe(true);
});

it('uses LF for Shift+Enter on Windows', () => {
expect(shiftEnterInputForEvent(keydown(), { isWindows: true }))
.toBe(SHIFT_ENTER_NEWLINE_INPUT);
});

it('does not match normal Enter, composing input, or non-Windows platforms', () => {
expect(shouldHandleWindowsShiftEnter(keydown({ shiftKey: false }), { isWindows: true })).toBe(false);
expect(shouldHandleWindowsShiftEnter(keydown({ isComposing: true }), { isWindows: true })).toBe(false);
expect(shouldHandleWindowsShiftEnter(keydown(), { isWindows: false })).toBe(false);
expect(shiftEnterInputForEvent(keydown(), { isWindows: false })).toBe(null);
});

it('leaves modified Enter chords alone', () => {
expect(shouldHandleWindowsShiftEnter(keydown({ ctrlKey: true }), { isWindows: true })).toBe(false);
expect(shouldHandleWindowsShiftEnter(keydown({ altKey: true }), { isWindows: true })).toBe(false);
expect(shouldHandleWindowsShiftEnter(keydown({ metaKey: true }), { isWindows: true })).toBe(false);
});
});
26 changes: 26 additions & 0 deletions lib/src/lib/terminal-keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type KeyboardEventLike = Pick<
KeyboardEvent,
'altKey' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey' | 'shiftKey' | 'type'
>;

export const SHIFT_ENTER_NEWLINE_INPUT = '\n';
Comment thread
dormouse-bot marked this conversation as resolved.

export function shouldHandleWindowsShiftEnter(
event: KeyboardEventLike,
options: { isWindows: boolean },
): boolean {
return shiftEnterInputForEvent(event, options) !== null;
}

export function shiftEnterInputForEvent(
event: KeyboardEventLike,
options: { isWindows: boolean },
): string | null {
if (!options.isWindows) return null;
if (event.type !== 'keydown') return null;
if (event.isComposing) return null;
if (event.key !== 'Enter') return null;
if (!event.shiftKey) return null;
if (event.ctrlKey || event.altKey || event.metaKey) return null;
return SHIFT_ENTER_NEWLINE_INPUT;
}
87 changes: 52 additions & 35 deletions lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Terminal, type IBufferRange } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes';
import { getPlatform, IS_MAC } from './platform';
import { getPlatform, IS_MAC, IS_WINDOWS } from './platform';
import { requestExternalLinkConfirmation } from './external-link-confirmation';
import { attachMouseModeObserver } from './mouse-mode-observer';
import {
Expand All @@ -28,6 +28,7 @@ import {
writeReplay,
} from './terminal-report-filter';
import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './terminal-theme';
import { shiftEnterInputForEvent } from './terminal-keyboard';
import {
ensureTerminalPaneState,
fillTerminalProcessCwdByPtyId,
Expand Down Expand Up @@ -116,16 +117,30 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi
},
});

// Only hosts that can run workbench commands (the VS Code adapter) opt in;
// on every other platform runWorkbenchCommand is undefined, so the chords
// stay in xterm exactly as before.
if (getPlatform().runWorkbenchCommand) {
// Two independent reasons to intercept keydown:
// - Windows Shift+Enter needs newline normalization so terminal TUIs see
// multiline input instead of Enter.
// - Hosts that run workbench commands (the VS Code adapter) opt in to
// forwarding F1/Ctrl+P/etc. up to the workbench; non-VS-Code hosts
// leave those chords alone so the browser default still fires.
if (IS_WINDOWS || getPlatform().runWorkbenchCommand) {
terminal.attachCustomKeyEventHandler((event) => {
const shiftEnterInput = shiftEnterInputForEvent(event, {
isWindows: IS_WINDOWS,
});
if (shiftEnterInput !== null) {
event.preventDefault();
event.stopPropagation();
terminal.input(shiftEnterInput, true);
return false;
}
const runWorkbenchCommand = getPlatform().runWorkbenchCommand;
if (!runWorkbenchCommand) return true;
const command = vscodeWorkbenchCommandForKeydown(event, { isMac: IS_MAC });
if (!command) return true;
event.preventDefault();
event.stopPropagation();
getPlatform().runWorkbenchCommand?.(command);
runWorkbenchCommand(command);
return true;
});
}
Expand Down Expand Up @@ -163,6 +178,36 @@ function wirePtyEvents(id: string, terminal: Terminal): () => void {
};
}

function handleTerminalInput(id: string, terminal: Terminal, data: string): void {
let input = data;
if (getMouseSelectionState(id).override !== 'off') {
input = stripMouseReportsFromInput(input);
if (input.length === 0) return;
}

const isReplayTerminalReport = inputIsReplayTerminalReport(input);

if (isReplayTerminalReport && registry.get(id)?.isReplaying) return;

if (!isReplayTerminalReport) {
markSessionTouched(id);
}

const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input);

if (!isSyntheticTerminalReport) {
recordTerminalUserInputByPtyId(id, input, makePromptLineReader(terminal));
const entry = registry.get(id);
const hadTodo = entry?.todo === true;
getPlatform().alertAttend(id);
if (hadTodo && inputContainsEnter(input)) {
getPlatform().alertClearTodo(id);
}
}

getPlatform().writePty(id, input);
}

/** xterm input/resize/render handlers. Returns a dispose. The render
* handler watches selectionBaseline (mutated by the mouse router) so the
* baseline is read by reference rather than captured. */
Expand All @@ -171,35 +216,7 @@ function wireXtermHandlers(
terminal: Terminal,
selectionBaselineRef: { current: string | null },
): () => void {
const inputDisposable = terminal.onData((data) => {
let input = data;
if (getMouseSelectionState(id).override !== 'off') {
input = stripMouseReportsFromInput(input);
if (input.length === 0) return;
}

const isReplayTerminalReport = inputIsReplayTerminalReport(input);

if (isReplayTerminalReport && registry.get(id)?.isReplaying) return;

if (!isReplayTerminalReport) {
markSessionTouched(id);
}

const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input);

if (!isSyntheticTerminalReport) {
recordTerminalUserInputByPtyId(id, input, makePromptLineReader(terminal));
const entry = registry.get(id);
const hadTodo = entry?.todo === true;
getPlatform().alertAttend(id);
if (hadTodo && inputContainsEnter(input)) {
getPlatform().alertClearTodo(id);
}
}

getPlatform().writePty(id, input);
});
const inputDisposable = terminal.onData((data) => handleTerminalInput(id, terminal, data));

const resizeDisposable = terminal.onResize(({ cols, rows }) => {
getPlatform().alertResize(id);
Expand Down
40 changes: 40 additions & 0 deletions lib/src/lib/terminal-registry.alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vi.mock('@xterm/xterm', () => {
writes: string[] = [];
private dataListeners = new Set<(data: string) => void>();
private resizeListeners = new Set<(size: { cols: number; rows: number }) => void>();
private keyHandler: ((event: KeyboardEvent) => boolean) | null = null;

parser = {
registerCsiHandler: () => ({ dispose: () => {} }),
Expand Down Expand Up @@ -62,6 +63,14 @@ vi.mock('@xterm/xterm', () => {
return { dispose: () => {} };
}

input(data: string): void {
this.emitInput(data);
}

attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void {
this.keyHandler = handler;
}

focus(): void {}

blur(): void {}
Expand All @@ -72,6 +81,21 @@ vi.mock('@xterm/xterm', () => {
this.dataListeners.forEach((listener) => listener(data));
}

emitKeyDown(init: Partial<KeyboardEvent> = {}): boolean | null {
return this.keyHandler?.({
altKey: false,
ctrlKey: false,
isComposing: false,
key: 'Enter',
metaKey: false,
preventDefault: vi.fn(),
shiftKey: true,
stopPropagation: vi.fn(),
type: 'keydown',
...init,
} as KeyboardEvent) ?? null;
}

emitResize(cols: number, rows: number): void {
this.resizeListeners.forEach((listener) => listener({ cols, rows }));
}
Expand All @@ -83,8 +107,11 @@ vi.mock('@xterm/xterm', () => {
vi.mock('./platform', async () => {
const actual = await vi.importActual<typeof import('./platform')>('./platform');
const fakePlatform = new actual.FakePtyAdapter();
// Force IS_WINDOWS so the Shift+Enter handler is attached regardless of
// the host OS this test runs on (Linux CI evaluates it false at module load).
return {
...actual,
IS_WINDOWS: true,
getPlatform: () => fakePlatform,
__fakePlatform: fakePlatform,
};
Expand Down Expand Up @@ -121,9 +148,12 @@ import {
import { pasteFilePaths } from './clipboard';

interface MockTerminalInstance {
modes: { bracketedPasteMode: boolean };
writes: string[];
emitInput(data: string): void;
emitKeyDown(init?: Partial<KeyboardEvent>): boolean | null;
emitResize(cols: number, rows: number): void;
input(data: string): void;
}

class MockElement {
Expand Down Expand Up @@ -279,6 +309,16 @@ describe('terminal-registry alert behavior', () => {
expect(isUntouched(id)).toBe(false);
});

it('sends LF through xterm input for Windows Shift+Enter before xterm handles Enter normally', () => {
const id = 'shift-enter-lf';
const entry = createSession(id);

const handled = entry.terminal.emitKeyDown();

expect(handled).toBe(false);
expect(entry.terminal.writes).toContain('\n');
});

it('does not mark synthetic terminal reports as touched', () => {
const id = 'synthetic-report-untouched';
const entry = createSession(id);
Expand Down
Loading