From e1ab779fe037fe7049ec1e4fe08f25418666a990 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 14 May 2026 17:48:22 +0800 Subject: [PATCH] Fix Shift+Enter in terminal input modes (#999) * Fix Shift+Enter terminal input modes * Advertise terminal input capabilities --- .../app/src/components/terminal-emulator.tsx | 8 +- packages/app/src/components/terminal-pane.tsx | 18 +- .../terminal-emulator-runtime.browser.test.ts | 114 +++++++++- .../runtime/terminal-emulator-runtime.test.ts | 32 +++ .../runtime/terminal-emulator-runtime.ts | 32 +++ packages/app/src/utils/terminal-keys.test.ts | 9 +- packages/app/src/utils/terminal-keys.ts | 6 +- .../server/daemon-e2e/terminal.e2e.test.ts | 101 +++++++++ .../src/shared/terminal-input-mode.test.ts | 81 ++++++++ .../server/src/shared/terminal-input-mode.ts | 194 ++++++++++++++++++ .../src/shared/terminal-key-input.test.ts | 35 +++- .../server/src/shared/terminal-key-input.ts | 54 ++++- .../terminal/terminal-session-controller.ts | 15 +- .../src/terminal/terminal.posix.test.ts | 1 + packages/server/src/terminal/terminal.ts | 14 ++ .../src/terminal/worker-terminal-manager.ts | 9 + 16 files changed, 702 insertions(+), 21 deletions(-) create mode 100644 packages/server/src/shared/terminal-input-mode.test.ts create mode 100644 packages/server/src/shared/terminal-input-mode.ts diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 9fedc44b3..854fa4edc 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -17,6 +17,7 @@ import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; +import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; @@ -129,6 +130,7 @@ interface TerminalEmulatorProps { meta: boolean; }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; + onInputModeChange?: (state: TerminalInputModeState) => Promise | void; onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; @@ -194,6 +196,7 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onInputModeChange, onRendererReadyChange, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, @@ -219,12 +222,14 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onInputModeChange, }); mountCallbacksRef.current = { onInput, onResize, onTerminalKey, onPendingModifiersConsumed, + onInputModeChange, }; const initialSnapshotRef = useRef(initialSnapshot); initialSnapshotRef.current = initialSnapshot; @@ -444,10 +449,11 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onInputModeChange, onOpenExternalUrl: openExternalUrl, }, }); - }, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]); + }, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]); useEffect(() => { runtimeRef.current?.setPendingModifiers({ pendingModifiers }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index ca139b484..1492f7b82 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -10,6 +10,7 @@ import { import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; +import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useAppVisible } from "@/hooks/use-app-visible"; @@ -188,6 +189,10 @@ export function TerminalPane({ const [resizeRequestToken, setResizeRequestToken] = useState(0); const emulatorRef = useRef(null); const terminalIdRef = useRef(terminalId); + const inputModeRef = useRef({ + kittyKeyboardFlags: 0, + win32InputMode: false, + }); const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); @@ -195,6 +200,10 @@ export function TerminalPane({ useEffect(() => { terminalIdRef.current = terminalId; + inputModeRef.current = { + kittyKeyboardFlags: 0, + win32InputMode: false, + }; }, [terminalId]); const requestTerminalFocus = useCallback(() => { @@ -413,7 +422,9 @@ export function TerminalPane({ return true; } - const encoded = encodeTerminalKeyInput(entry.input); + const encoded = encodeTerminalKeyInput(entry.input, { + inputMode: inputModeRef.current, + }); if (encoded.length === 0) { return true; } @@ -595,6 +606,10 @@ export function TerminalPane({ clearPendingModifiers(); }, [clearPendingModifiers]); + const handleInputModeChange = useCallback((state: TerminalInputModeState) => { + inputModeRef.current = state; + }, []); + const toggleModifier = useCallback( (modifier: keyof ModifierState) => { setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); @@ -674,6 +689,7 @@ export function TerminalPane({ onInput={handleTerminalData} onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} + onInputModeChange={handleInputModeChange} onPendingModifiersConsumed={handlePendingModifiersConsumed} pendingModifiers={modifiers} focusRequestToken={focusRequestToken} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 623342c3a..da95f6d5a 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -1,5 +1,6 @@ import { page } from "@vitest/browser/context"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; vi.mock("@xterm/addon-webgl", () => ({ @@ -15,6 +16,14 @@ interface TerminalSize { cols: number; } +interface TerminalKeyRecord { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; +} + type BrowserTerminal = TerminalSize & { refresh: (start: number, end: number) => void; reset: () => void; @@ -26,6 +35,8 @@ interface MountedTerminal { runtime: TerminalEmulatorRuntime; inputs: string[]; sizes: TerminalSize[]; + terminalKeys: TerminalKeyRecord[]; + inputModeChanges: TerminalInputModeState[]; } const mountedTerminals: MountedTerminal[] = []; @@ -67,6 +78,8 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe const sizes: TerminalSize[] = []; const inputs: string[] = []; + const terminalKeys: TerminalKeyRecord[] = []; + const inputModeChanges: TerminalInputModeState[] = []; const runtime = new TerminalEmulatorRuntime(); runtime.setCallbacks({ callbacks: { @@ -76,6 +89,12 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe onResize: (size) => { sizes.push(size); }, + onTerminalKey: (key) => { + terminalKeys.push(key); + }, + onInputModeChange: (state) => { + inputModeChanges.push(state); + }, }, }); runtime.mount({ @@ -89,7 +108,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe }, }); - const mounted = { host, root, runtime, inputs, sizes }; + const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges }; mountedTerminals.push(mounted); return mounted; } @@ -110,6 +129,32 @@ function getBrowserTerminal(): BrowserTerminal { return terminal; } +function dispatchTerminalKey(input: { + host: HTMLElement; + key: string; + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +}): boolean { + const textarea = input.host.querySelector("textarea"); + if (!textarea) { + throw new Error("Expected xterm textarea to be mounted"); + } + textarea.focus(); + return textarea.dispatchEvent( + new KeyboardEvent("keydown", { + key: input.key, + shiftKey: input.shiftKey ?? false, + ctrlKey: input.ctrlKey ?? false, + altKey: input.altKey ?? false, + metaKey: input.metaKey ?? false, + bubbles: true, + cancelable: true, + }), + ); +} + afterEach(() => { for (const mounted of mountedTerminals.splice(0)) { mounted.runtime.unmount(); @@ -162,6 +207,73 @@ describe("terminal emulator runtime in a real browser", () => { expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]); }); + it("intercepts Shift+Enter only after enhanced terminal input mode is active", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ + host: mounted.host, + key: "Enter", + shiftKey: true, + }); + await nextFrame(); + + expect(mounted.terminalKeys).toEqual([]); + + mounted.runtime.write({ text: "\x1b[>7u" }); + await waitFor({ + predicate: () => + mounted.inputModeChanges.some( + (state) => state.kittyKeyboardFlags === 7 && !state.win32InputMode, + ), + }); + + dispatchTerminalKey({ + host: mounted.host, + key: "Enter", + shiftKey: true, + }); + await nextFrame(); + + expect(mounted.terminalKeys).toEqual([ + { + key: "Enter", + ctrl: false, + shift: true, + alt: false, + meta: false, + }, + ]); + + mounted.terminalKeys.length = 0; + mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" }); + await waitFor({ + predicate: () => + mounted.inputModeChanges.some( + (state) => state.kittyKeyboardFlags === 0 && state.win32InputMode, + ), + }); + + dispatchTerminalKey({ + host: mounted.host, + key: "Enter", + shiftKey: true, + }); + await nextFrame(); + + expect(mounted.terminalKeys).toEqual([ + { + key: "Enter", + ctrl: false, + shift: true, + alt: false, + meta: false, + }, + ]); + }); + it.each([ { name: "DA1", bytes: "\x1b[c" }, { name: "DA1-zero", bytes: "\x1b[0c" }, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 078bccf34..b16aa4c4e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -176,6 +176,38 @@ describe("terminal-emulator-runtime", () => { expect(onCommitted).toHaveBeenCalledTimes(1); }); + it("reports input mode changes from terminal output and resets them on snapshots", () => { + const { runtime, writeCallbacks } = createRuntimeWithTerminal(); + const inputModeChanges: Array<{ kittyKeyboardFlags: number; win32InputMode: boolean }> = []; + runtime.setCallbacks({ + callbacks: { + onInputModeChange: (state) => { + inputModeChanges.push(state); + }, + }, + }); + + runtime.write({ text: "\x1b[>7u" }); + runtime.renderSnapshot({ + state: { + rows: 2, + cols: 8, + scrollback: [], + grid: [[{ char: "$" }, { char: " " }]], + cursor: { + row: 0, + col: 2, + }, + }, + }); + writeCallbacks[0]?.(); + + expect(inputModeChanges).toEqual([ + { kittyKeyboardFlags: 7, win32InputMode: false }, + { kittyKeyboardFlags: 0, win32InputMode: false }, + ]); + }); + it("ignores stale duplicate write callbacks from a previous operation", () => { const { runtime, writeCallbacks } = createRuntimeWithTerminal(); const committed: string[] = []; diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index ae0fa6b2c..aeb18d7b0 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -8,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; import { Terminal, type ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; +import { + type TerminalInputModeState, + TerminalInputModeTracker, + terminalInputModeStatesEqual, +} from "@server/shared/terminal-input-mode"; import { type PendingTerminalModifiers, isTerminalModifierDomKey, @@ -37,6 +42,7 @@ export interface TerminalEmulatorRuntimeCallbacks { }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; onOpenExternalUrl?: (url: string) => Promise | void; + onInputModeChange?: (state: TerminalInputModeState) => Promise | void; } interface TerminalEmulatorRuntimeDisposables { @@ -125,6 +131,8 @@ export class TerminalEmulatorRuntime { private inFlightOutputOperation: TerminalOutputOperation | null = null; private inFlightOutputOperationTimeout: ReturnType | null = null; private suppressInput = false; + private readonly inputModeTracker = new TerminalInputModeTracker(); + private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState(); private handleVisibilityRestore = (): void => { if (typeof document !== "undefined" && document.visibilityState !== "visible") { @@ -152,6 +160,8 @@ export class TerminalEmulatorRuntime { input.host.innerHTML = ""; this.lastSize = null; + this.inputModeTracker.reset(); + this.emitInputModeChange(); const terminal = new Terminal({ allowProposedApi: true, @@ -353,6 +363,7 @@ export class TerminalEmulatorRuntime { altKey: event.altKey, metaKey: event.metaKey, pendingModifiers: this.pendingModifiers, + enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(), }) ) { return true; @@ -606,6 +617,8 @@ export class TerminalEmulatorRuntime { this.fitAndEmitResize = null; this.lastSize = null; this.suppressInput = false; + this.inputModeTracker.reset(); + this.emitInputModeChange(); } private processOutputQueue(): void { @@ -640,12 +653,16 @@ export class TerminalEmulatorRuntime { }; if (operation.type === "clear") { + this.inputModeTracker.reset(); + this.emitInputModeChange(); terminal.reset(); finalizeOperation(operation); return; } if (operation.type === "snapshot") { + this.inputModeTracker.reset(); + this.emitInputModeChange(); try { if ( typeof operation.cols === "number" && @@ -661,6 +678,12 @@ export class TerminalEmulatorRuntime { } const text = operation.text; + if (operation.type === "write") { + const result = this.inputModeTracker.feed(text); + if (result.changed) { + this.emitInputModeChange(); + } + } this.inFlightOutputOperationTimeout = setTimeout(() => { finalizeOperation(operation); }, OUTPUT_OPERATION_TIMEOUT_MS); @@ -682,6 +705,15 @@ export class TerminalEmulatorRuntime { this.inFlightOutputOperationTimeout = null; } + private emitInputModeChange(): void { + const state = this.inputModeTracker.getState(); + if (terminalInputModeStatesEqual(state, this.lastInputModeState)) { + return; + } + this.lastInputModeState = state; + this.callbacks.onInputModeChange?.(state); + } + private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void { const documentElement = document.documentElement; const body = document.body; diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 96ac8340e..07dff7b1c 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -95,7 +95,7 @@ describe("terminal key helpers", () => { ).toBe(true); }); - it("intercepts Enter with DOM shift modifier for CSI u encoding", () => { + it("does not intercept modified Enter before enhanced input mode is active", () => { expect( shouldInterceptDomTerminalKey({ key: "Enter", @@ -105,10 +105,10 @@ describe("terminal key helpers", () => { metaKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, }), - ).toBe(true); + ).toBe(false); }); - it("intercepts Enter with any DOM modifier for CSI u encoding", () => { + it("intercepts Enter with any DOM modifier after enhanced input mode is active", () => { expect( shouldInterceptDomTerminalKey({ key: "Enter", @@ -117,6 +117,7 @@ describe("terminal key helpers", () => { altKey: false, metaKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, + enhancedInputActive: true, }), ).toBe(true); expect( @@ -127,6 +128,7 @@ describe("terminal key helpers", () => { altKey: true, metaKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, + enhancedInputActive: true, }), ).toBe(true); expect( @@ -137,6 +139,7 @@ describe("terminal key helpers", () => { altKey: false, metaKey: true, pendingModifiers: { ctrl: false, shift: false, alt: false }, + enhancedInputActive: true, }), ).toBe(true); }); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 4ce6cf6cf..4a70cb312 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -89,15 +89,13 @@ export function shouldInterceptDomTerminalKey(args: { altKey: boolean; metaKey: boolean; pendingModifiers: PendingTerminalModifiers; + enhancedInputActive?: boolean; }): boolean { if (hasPendingTerminalModifiers(args.pendingModifiers)) { return true; } - // xterm.js sends plain \r for Enter regardless of modifiers. - // Intercept modified Enter so it gets CSI u encoding (Kitty keyboard protocol), - // which Claude Code and other TUI apps use for Shift+Enter newlines. if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) { - return true; + return Boolean(args.enhancedInputActive); } return false; } diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 8217ea782..a5217c601 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -457,6 +457,29 @@ function sendRawTerminalInput(ws: WebSocket, slot: number, data: string): void { ); } +function createInputModeProbeOptions(): { command: string; args: string[] } { + return { + command: process.execPath, + args: [ + "-e", + ` +process.stdin.setRawMode?.(true); +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + const text = chunk.toString("utf8"); + if (text.includes("e")) { + process.stdout.write("\\x1b[>7uKITTY\\n"); + } + if (text.includes("w")) { + process.stdout.write("\\x1b[?9001hWIN32\\n"); + } +}); +setInterval(() => {}, 1000); +`, + ], + }; +} + async function repeatRawTerminalInput(input: { ws: WebSocket; slot: number; @@ -945,6 +968,84 @@ test("disconnect and reconnect both receive the current snapshot", async () => { rmSync(cwd, { recursive: true, force: true }); }, 30000); +test("reconnected terminal streams replay active input modes after the snapshot", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd, "input-mode-probe", undefined, { + ...createInputModeProbeOptions(), + }); + const terminalId = created.terminal!.id; + const firstWs = await connectRawWebSocket(ctx.daemon.port); + + try { + const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first"); + await waitForRawBinaryFrame( + firstWs, + (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + ); + sendRawTerminalInput(firstWs, firstSlot, "e"); + await waitForRawBinaryFrame( + firstWs, + (frame) => + frame.slot === firstSlot && + frame.opcode === TerminalStreamOpcode.Output && + getFrameText(frame).includes("KITTY"), + ); + sendRawTerminalInput(firstWs, firstSlot, "w"); + await waitForRawBinaryFrame( + firstWs, + (frame) => + frame.slot === firstSlot && + frame.opcode === TerminalStreamOpcode.Output && + getFrameText(frame).includes("WIN32"), + ); + } finally { + await closeWebSocket(firstWs); + } + + const secondWs = await connectRawWebSocket(ctx.daemon.port); + try { + const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second"); + await waitForRawBinaryFrame( + secondWs, + (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + ); + const replay = await waitForRawBinaryFrame( + secondWs, + (frame) => + frame.slot === secondSlot && + frame.opcode === TerminalStreamOpcode.Output && + getFrameText(frame).includes("\x1b[=7;1u"), + ); + + expect(getFrameText(replay)).toContain("\x1b[=7;1u"); + expect(getFrameText(replay)).toContain("\x1b[?9001h"); + } finally { + await closeWebSocket(secondWs); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("reconnected terminal streams do not replay input modes before they are enabled", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd, "input-mode-inactive-probe", undefined, { + ...createInputModeProbeOptions(), + }); + const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); + + try { + const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive"); + await waitForRawBinaryFrame( + ws, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + ); + await waitForNoRawBinaryFrame(ws, 300); + } finally { + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("fast output to a slow websocket client falls back to a snapshot", async () => { const cwd = tmpCwd(); const created = await ctx.client.createTerminal(cwd); diff --git a/packages/server/src/shared/terminal-input-mode.test.ts b/packages/server/src/shared/terminal-input-mode.test.ts new file mode 100644 index 000000000..813ee89f1 --- /dev/null +++ b/packages/server/src/shared/terminal-input-mode.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { TerminalInputModeTracker } from "./terminal-input-mode.js"; + +describe("TerminalInputModeTracker", () => { + it("activates from a pushed Kitty keyboard mode and builds a replay preamble", () => { + const tracker = new TerminalInputModeTracker(); + + expect(tracker.feed("\x1b[>7u").changed).toBe(true); + + expect(tracker.supportsModifiedEnter()).toBe(true); + expect(tracker.getKittyKeyboardFlags()).toBe(7); + expect(tracker.getPreamble()).toBe("\x1b[=7;1u"); + }); + + it("tracks split terminal output chunks", () => { + const tracker = new TerminalInputModeTracker(); + + tracker.feed("\x1b[>"); + const result = tracker.feed("1u"); + + expect(result.changed).toBe(true); + expect(tracker.getKittyKeyboardFlags()).toBe(1); + }); + + it("restores pushed Kitty keyboard modes when the foreground program pops them", () => { + const tracker = new TerminalInputModeTracker(); + + tracker.feed("\x1b[>1u"); + tracker.feed("\x1b[>7u"); + expect(tracker.getKittyKeyboardFlags()).toBe(7); + + expect(tracker.feed("\x1b[ { + const tracker = new TerminalInputModeTracker(); + tracker.feed("\x1b[=3;1u"); + + expect(tracker.feed("\x1b[?u").responses).toEqual(["\x1b[?3u"]); + }); + + it("tracks ConPTY Win32 input mode and replays it after snapshots", () => { + const tracker = new TerminalInputModeTracker(); + + expect(tracker.feed("\x1b[?9001h").changed).toBe(true); + + expect(tracker.getState()).toEqual({ + kittyKeyboardFlags: 0, + win32InputMode: true, + }); + expect(tracker.supportsModifiedEnter()).toBe(true); + expect(tracker.getPreamble()).toBe("\x1b[?9001h"); + + expect(tracker.feed("\x1b[?9001l").changed).toBe(true); + expect(tracker.supportsModifiedEnter()).toBe(false); + }); + + it("keeps Kitty and Win32 input modes independent", () => { + const tracker = new TerminalInputModeTracker(); + + tracker.feed("\x1b[>7u\x1b[?9001h"); + + expect(tracker.getState()).toEqual({ + kittyKeyboardFlags: 7, + win32InputMode: true, + }); + expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h"); + }); + + it("ignores encoded key input sequences", () => { + const tracker = new TerminalInputModeTracker(); + + tracker.feed("\x1b[13;2u"); + + expect(tracker.supportsModifiedEnter()).toBe(false); + }); +}); diff --git a/packages/server/src/shared/terminal-input-mode.ts b/packages/server/src/shared/terminal-input-mode.ts new file mode 100644 index 000000000..e9e15d5ce --- /dev/null +++ b/packages/server/src/shared/terminal-input-mode.ts @@ -0,0 +1,194 @@ +export interface TerminalInputModeFeedResult { + changed: boolean; + responses: string[]; +} + +export interface TerminalInputModeState { + kittyKeyboardFlags: number; + win32InputMode: boolean; +} + +export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = { + kittyKeyboardFlags: 0, + win32InputMode: false, +}; + +const ESC = String.fromCharCode(0x1b); +const WIN32_INPUT_MODE = 9001; +const CSI_INPUT_MODE_SEQUENCE = new RegExp( + `${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`, + "g", +); +const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`); + +function parseFirstParam(params: string): number | null { + const first = params.split(";")[0]; + if (!first || !/^\d+$/.test(first)) { + return null; + } + return Number(first); +} + +function parseSecondParam(params: string): number | null { + const second = params.split(";")[1]; + if (!second || !/^\d+$/.test(second)) { + return null; + } + return Number(second); +} + +function parsePrivateModeParams(params: string): Set { + const modes = new Set(); + for (const param of params.split(";")) { + if (/^\d+$/.test(param)) { + modes.add(Number(param)); + } + } + return modes; +} + +export function terminalInputModeSupportsModifiedEnter(state: TerminalInputModeState): boolean { + return state.kittyKeyboardFlags > 0 || state.win32InputMode; +} + +export function terminalInputModeStatesEqual( + left: TerminalInputModeState, + right: TerminalInputModeState, +): boolean { + return ( + left.kittyKeyboardFlags === right.kittyKeyboardFlags && + left.win32InputMode === right.win32InputMode + ); +} + +export class TerminalInputModeTracker { + private kittyKeyboardFlags = 0; + private win32InputMode = false; + private readonly kittyKeyboardStack: number[] = []; + private pending = ""; + + feed(data: string): TerminalInputModeFeedResult { + if (data.length === 0) { + return { changed: false, responses: [] }; + } + + const text = `${this.pending}${data}`; + this.pending = ""; + + let changed = false; + const responses: string[] = []; + let consumedUntil = 0; + + CSI_INPUT_MODE_SEQUENCE.lastIndex = 0; + for (;;) { + const match = CSI_INPUT_MODE_SEQUENCE.exec(text); + if (!match) { + break; + } + consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex; + + if (match[4]) { + changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed; + continue; + } + + const result = this.applyKittyKeyboardSequence(match[1] ?? "", match[2] ?? ""); + changed = changed || result.changed; + responses.push(...result.responses); + } + + const tail = text.slice(consumedUntil); + const pendingStart = tail.lastIndexOf(`${ESC}[`); + if (pendingStart >= 0) { + const pending = tail.slice(pendingStart); + if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) { + this.pending = pending; + } + } + + return { changed, responses }; + } + + reset(): void { + this.kittyKeyboardFlags = 0; + this.win32InputMode = false; + this.kittyKeyboardStack.length = 0; + this.pending = ""; + } + + getState(): TerminalInputModeState { + return { + kittyKeyboardFlags: this.kittyKeyboardFlags, + win32InputMode: this.win32InputMode, + }; + } + + getKittyKeyboardFlags(): number { + return this.kittyKeyboardFlags; + } + + supportsModifiedEnter(): boolean { + return terminalInputModeSupportsModifiedEnter(this.getState()); + } + + getPreamble(): string { + const parts: string[] = []; + if (this.kittyKeyboardFlags > 0) { + parts.push(`\x1b[=${this.kittyKeyboardFlags};1u`); + } + if (this.win32InputMode) { + parts.push("\x1b[?9001h"); + } + return parts.join(""); + } + + private applyKittyKeyboardSequence( + prefix: string, + params: string, + ): { changed: boolean; responses: string[] } { + const previousFlags = this.kittyKeyboardFlags; + + switch (prefix) { + case ">": { + this.kittyKeyboardStack.push(this.kittyKeyboardFlags); + this.kittyKeyboardFlags = parseFirstParam(params) ?? 1; + break; + } + case "=": { + const mode = parseSecondParam(params) ?? 1; + this.kittyKeyboardFlags = mode === 0 ? 0 : (parseFirstParam(params) ?? 0); + break; + } + case "<": { + const count = Math.max(1, parseFirstParam(params) ?? 1); + for (let index = 0; index < count; index += 1) { + this.kittyKeyboardFlags = this.kittyKeyboardStack.pop() ?? 0; + } + break; + } + case "?": + return { + changed: false, + responses: [`\x1b[?${this.kittyKeyboardFlags}u`], + }; + default: + return { changed: false, responses: [] }; + } + + return { + changed: this.kittyKeyboardFlags !== previousFlags, + responses: [], + }; + } + + private applyPrivateModeSequence(params: string, final: string): boolean { + const modes = parsePrivateModeParams(params); + if (!modes.has(WIN32_INPUT_MODE)) { + return false; + } + + const previous = this.win32InputMode; + this.win32InputMode = final === "h"; + return this.win32InputMode !== previous; + } +} diff --git a/packages/server/src/shared/terminal-key-input.test.ts b/packages/server/src/shared/terminal-key-input.test.ts index 239fb86c1..7bb46437e 100644 --- a/packages/server/src/shared/terminal-key-input.test.ts +++ b/packages/server/src/shared/terminal-key-input.test.ts @@ -19,15 +19,36 @@ describe("encodeTerminalKeyInput", () => { expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f"); }); - it("encodes shift+enter as kitty keyboard protocol CSI u", () => { - expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u"); + it("keeps modified Enter as carriage return before enhanced input mode is active", () => { + expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\r"); }); - it("encodes enter with modifiers using CSI u", () => { - expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\x1b[13;5u"); - expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b[13;3u"); - expect(encodeTerminalKeyInput({ key: "Enter", meta: true })).toBe("\x1b[13;9u"); - expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\x1b[13;6u"); + it("encodes Enter with modifiers using CSI u after Kitty keyboard mode is active", () => { + const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: false } }; + + expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe("\x1b[13;2u"); + expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true }, options)).toBe("\x1b[13;5u"); + expect(encodeTerminalKeyInput({ key: "Enter", alt: true }, options)).toBe("\x1b[13;3u"); + expect(encodeTerminalKeyInput({ key: "Enter", meta: true }, options)).toBe("\x1b[13;9u"); + expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true }, options)).toBe( + "\x1b[13;6u", + ); + }); + + it("encodes Shift+Enter using Win32 input mode when ConPTY requests it", () => { + const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: true } }; + + expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe( + "\x1b[13;28;13;1;16;1_", + ); + }); + + it("prefers Win32 input mode over CSI u when both modes are active", () => { + const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: true } }; + + expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe( + "\x1b[13;28;13;1;16;1_", + ); }); it("returns empty string for unsupported keys", () => { diff --git a/packages/server/src/shared/terminal-key-input.ts b/packages/server/src/shared/terminal-key-input.ts index 40614e53a..3d1cd5cce 100644 --- a/packages/server/src/shared/terminal-key-input.ts +++ b/packages/server/src/shared/terminal-key-input.ts @@ -1,3 +1,5 @@ +import type { TerminalInputModeState } from "./terminal-input-mode.js"; + export interface TerminalKeyInput { key: string; ctrl?: boolean; @@ -6,6 +8,14 @@ export interface TerminalKeyInput { meta?: boolean; } +export interface TerminalKeyInputEncodingOptions { + inputMode?: TerminalInputModeState; +} + +const WIN32_LEFT_ALT_PRESSED = 0x0002; +const WIN32_LEFT_CTRL_PRESSED = 0x0008; +const WIN32_SHIFT_PRESSED = 0x0010; + function modifierParam(input: TerminalKeyInput): number { let value = 1; if (input.shift) value += 1; @@ -15,6 +25,40 @@ function modifierParam(input: TerminalKeyInput): number { return value; } +function win32ControlKeyState(input: TerminalKeyInput): number { + let value = 0; + if (input.shift) value += WIN32_SHIFT_PRESSED; + if (input.ctrl) value += WIN32_LEFT_CTRL_PRESSED; + if (input.alt) value += WIN32_LEFT_ALT_PRESSED; + return value; +} + +function shouldUseWin32InputMode( + input: TerminalKeyInput, + options: TerminalKeyInputEncodingOptions, +): boolean { + if (!options.inputMode?.win32InputMode) { + return false; + } + return Boolean(input.shift || input.ctrl || input.alt); +} + +function shouldUseKittyKeyboardMode( + input: TerminalKeyInput, + options: TerminalKeyInputEncodingOptions, +): boolean { + if ((options.inputMode?.kittyKeyboardFlags ?? 0) <= 0) { + return false; + } + return Boolean(input.shift || input.ctrl || input.alt || input.meta); +} + +function encodeWin32EnterKeyInput(input: TerminalKeyInput): string { + const controlKeyState = win32ControlKeyState(input); + // ConPTY Win32 input mode: CSI Vk;Sc;Uc;Kd;Cs;Rc _ + return `\x1b[13;28;13;1;${controlKeyState};1_`; +} + function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string { return input.alt ? `\x1b${sequence}` : sequence; } @@ -144,7 +188,10 @@ function encodeNavigationKey(key: string, input: TerminalKeyInput): string | nul } } -export function encodeTerminalKeyInput(input: TerminalKeyInput): string { +export function encodeTerminalKeyInput( + input: TerminalKeyInput, + options: TerminalKeyInputEncodingOptions = {}, +): string { const key = input.key; if (!key) { return ""; @@ -157,7 +204,10 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string { switch (key) { case "Enter": { const mod = modifierParam(input); - if (mod > 1) { + if (mod > 1 && shouldUseWin32InputMode(input, options)) { + return encodeWin32EnterKeyInput(input); + } + if (mod > 1 && shouldUseKittyKeyboardMode(input, options)) { return `\x1b[13;${mod}u`; } return "\r"; diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index f2d4aa6a8..103a6b4b3 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -681,7 +681,13 @@ export class TerminalSessionController { return; } - if (!this.terminalManager?.getTerminal(activeStream.terminalId)) { + const terminalManager = this.terminalManager; + if (!terminalManager) { + this.detachStream(activeStream.terminalId, { emitExit: true }); + return; + } + const terminal = terminalManager.getTerminal(activeStream.terminalId); + if (!terminal) { this.detachStream(activeStream.terminalId, { emitExit: true }); return; } @@ -689,7 +695,7 @@ export class TerminalSessionController { activeStream.outputCoalescer.flush(); activeStream.snapshotInFlight = true; try { - const snapshot = await this.terminalManager.getTerminalState(activeStream.terminalId); + const snapshot = await terminalManager.getTerminalState(activeStream.terminalId); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return; } @@ -706,6 +712,11 @@ export class TerminalSessionController { }), ); + const replayPreamble = terminal.getReplayPreamble(); + if (replayPreamble.length > 0) { + activeStream.outputCoalescer.handle(replayPreamble); + } + const bufferedOutputs = activeStream.bufferedOutputs.splice( 0, activeStream.bufferedOutputs.length, diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts index 7c61b4fe9..32f16d451 100644 --- a/packages/server/src/terminal/terminal.posix.test.ts +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -234,6 +234,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { }); expect(resolvedEnv.TERM).toBe("xterm-256color"); + expect(resolvedEnv.TERM_PROGRAM).toBe("kitty"); expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir"); expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 0f761954e..56f54697c 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; import type { TerminalCell, TerminalState } from "../shared/messages.js"; +import { TerminalInputModeTracker } from "../shared/terminal-input-mode.js"; const { Terminal } = xterm; const require = createRequire(import.meta.url); @@ -54,6 +55,7 @@ export interface TerminalSession { getSize(): { rows: number; cols: number }; getState(): TerminalState; getStateSnapshot(): TerminalStateSnapshot; + getReplayPreamble(): string; getTitle(): string | undefined; getExitInfo(): TerminalExitInfo | null; kill(): void; @@ -219,6 +221,7 @@ export function buildTerminalEnvironment( ): Record { const baseEnv: Record = createExternalProcessEnv(process.env, input.env, { TERM: "xterm-256color", + TERM_PROGRAM: "kitty", }); if (basename(input.shell) !== "zsh") { @@ -551,6 +554,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; let stateRevision = 0; + const inputModeTracker = new TerminalInputModeTracker(); // Create xterm.js headless terminal const terminal = new Terminal({ @@ -703,6 +707,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (killed) return; + const inputModeUpdate = inputModeTracker.feed(data); + for (const response of inputModeUpdate.responses) { + ptyProcess.write(response); + } recentOutputText = `${recentOutputText}${data}`; if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) { recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT); @@ -803,6 +812,10 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>; exitListeners: Set<(info: TerminalExitInfo) => void>; @@ -169,6 +171,7 @@ export function createWorkerTerminalManager( const record: WorkerTerminalRecord = { info: cloneTerminalInfo(input.info), state: input.state, + inputModeTracker: new TerminalInputModeTracker(), exitInfo: null, messageListeners: new Set(), exitListeners: new Set(), @@ -247,6 +250,9 @@ export function createWorkerTerminalManager( revision: 0, }; }, + getReplayPreamble(): string { + return record.inputModeTracker.getPreamble(); + }, getTitle(): string | undefined { return record.info.title; }, @@ -302,6 +308,9 @@ export function createWorkerTerminalManager( if (message.message.type === "snapshot") { record.state = message.message.state; } + if (message.message.type === "output") { + record.inputModeTracker.feed(message.message.data); + } for (const listener of Array.from(record.messageListeners)) { listener(message.message); }