diff --git a/docs/specs/layout.md b/docs/specs/layout.md index a137d92..ddbe75e 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -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 diff --git a/lib/src/lib/platform/index.ts b/lib/src/lib/platform/index.ts index 3b0a544..880e2e2 100644 --- a/lib/src/lib/platform/index.ts +++ b/lib/src/lib/platform/index.ts @@ -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). */ diff --git a/lib/src/lib/terminal-keyboard.test.ts b/lib/src/lib/terminal-keyboard.test.ts new file mode 100644 index 0000000..ef028a9 --- /dev/null +++ b/lib/src/lib/terminal-keyboard.test.ts @@ -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 { + 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); + }); +}); diff --git a/lib/src/lib/terminal-keyboard.ts b/lib/src/lib/terminal-keyboard.ts new file mode 100644 index 0000000..ee4d7d2 --- /dev/null +++ b/lib/src/lib/terminal-keyboard.ts @@ -0,0 +1,26 @@ +type KeyboardEventLike = Pick< + KeyboardEvent, + 'altKey' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey' | 'shiftKey' | 'type' +>; + +export const SHIFT_ENTER_NEWLINE_INPUT = '\n'; + +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; +} diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 76f254b..c4933bf 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -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 { @@ -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, @@ -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; }); } @@ -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. */ @@ -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); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 8422d85..cf0c1a6 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -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: () => {} }), @@ -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 {} @@ -72,6 +81,21 @@ vi.mock('@xterm/xterm', () => { this.dataListeners.forEach((listener) => listener(data)); } + emitKeyDown(init: Partial = {}): 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 })); } @@ -83,8 +107,11 @@ vi.mock('@xterm/xterm', () => { vi.mock('./platform', async () => { const actual = await vi.importActual('./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, }; @@ -121,9 +148,12 @@ import { import { pasteFilePaths } from './clipboard'; interface MockTerminalInstance { + modes: { bracketedPasteMode: boolean }; writes: string[]; emitInput(data: string): void; + emitKeyDown(init?: Partial): boolean | null; emitResize(cols: number, rows: number): void; + input(data: string): void; } class MockElement { @@ -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);