diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f1420..8eafe0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). Release checklist in [deploy.md](docs/specs/deploy.md). +## [Unreleased] +### Fixed +- On Windows, Shift+Enter and Ctrl+J now insert a newline (instead of submitting or doing nothing) in TUIs that read keyboard input via the Console API behind ConPTY, such as Codex. Dormouse advertises win32-input-mode so those apps receive faithful Win32 key events, the same way Windows Terminal does. Claude Code and macOS/Linux are unaffected — they continue to use the kitty keyboard protocol ([#117](https://github.com/diffplug/dormouse/pull/117)). + + ## [0.11.0] - 2026-05-28 ### Added - Pane titles now read the running command off the rendered prompt line, so the title is correct whether the command was typed, recalled from history, or pasted, and it survives session restore / VS Code panel reopen ([#102](https://github.com/diffplug/dormouse/pull/102)). diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 603fc6e..b5f3967 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -86,6 +86,7 @@ The vast majority of CSI handling is delegated to xterm.js. Dormouse only interv | `CSI ? ... h` (DECSET) | Private-mode set, including mouse tracking and bracketed paste | Observed via an xterm.js parser hook that returns false (xterm still handles the sequence); the mouse-selection store reads `terminal.modes` in a microtask. | `docs/specs/mouse-and-clipboard.md` | | `CSI ? ... l` (DECRST) | Private-mode reset, including mouse tracking and bracketed paste | Same observation pattern as DECSET. | `docs/specs/mouse-and-clipboard.md` | | Kitty keyboard protocol | Disambiguated key-event reporting (CSI u with modifiers, e.g. Shift+Enter distinguishable from Enter) | Enabled by passing `vtExtensions: { kittyKeyboard: true }` to the xterm.js `Terminal` constructor; xterm.js handles the push/pop (`CSI > u` / `CSI < u`) and the modified key reports. | `lib/src/lib/terminal-lifecycle.ts` | +| `CSI ? 9001 h/l` (win32-input-mode) | Faithful Win32 `INPUT_RECORD` key reporting for ConPTY apps that read via the Console API (e.g. Codex on Windows), which cannot negotiate the kitty protocol there. Without it, a key like Shift+Enter or Ctrl+J reaches the app as a bare byte (or not at all) and is not recognized as a modified key. | Advertised **only on Windows** by passing `vtExtensions: { win32InputMode: IS_WINDOWS }` to the xterm.js `Terminal` constructor; xterm.js answers the program's `CSI ? 9001 h` and then emits `CSI Vk;Sc;Uc;Kd;Cs;Rc _` key records. **Mutually exclusive with the kitty protocol** — xterm.js gives win32-input-mode precedence per keypress — and ConPTY's conhost enables it proactively, so a per-pane arbiter (`keyboard-protocol-arbiter.ts`) toggles the option off when an app pushes kitty (`CSI > … u`) and back on when it pops (`CSI < … u`), so kitty-based TUIs (Claude Code) and win32 TUIs (Codex) both work in the same window. | `lib/src/lib/terminal-lifecycle.ts`, `lib/src/lib/keyboard-protocol-arbiter.ts` | ### Replay-time CSI filtering @@ -98,7 +99,7 @@ During `pty:replay`, Dormouse reconstructs scrollback by replaying saved bytes t - Focus reports (`CSI I` / `CSI O`) - OSC and DCS replies of any shape -This filter is limited to *terminal-generated reports*. User keyboard escape sequences — arrows, function keys, bracketed paste, and modified key reports from the kitty keyboard protocol — must not be swallowed. See `docs/specs/transport.md` and `docs/specs/layout.md` for the contexts that invoke the filter. +This filter is limited to *terminal-generated reports*. User keyboard escape sequences — arrows, function keys, bracketed paste, modified key reports from the kitty keyboard protocol, and win32-input-mode key records (`CSI …_`) — must not be swallowed. See `docs/specs/transport.md` and `docs/specs/layout.md` for the contexts that invoke the filter. ### Pass-through and fail-inertly diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts index 239c7a2..35b4fb2 100644 --- a/lib/src/lib/clipboard.test.ts +++ b/lib/src/lib/clipboard.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('./platform', () => ({ IS_MAC: false, + IS_WINDOWS: false, getPlatform: () => ({ readClipboardFilePaths: mocks.readClipboardFilePaths, readClipboardImageAsFilePath: mocks.readClipboardImageAsFilePath, diff --git a/lib/src/lib/keyboard-protocol-arbiter.test.ts b/lib/src/lib/keyboard-protocol-arbiter.test.ts new file mode 100644 index 0000000..136c2f7 --- /dev/null +++ b/lib/src/lib/keyboard-protocol-arbiter.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; +import { attachKeyboardProtocolArbiter } from './keyboard-protocol-arbiter'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +type PushHandler = () => boolean; +type PopHandler = (params: (number | number[])[]) => boolean; + +interface MockHandlers { + push: Array; + pop: Array; +} + +function buildMockTerminal( + initial: { kittyKeyboard?: boolean; win32InputMode?: boolean } = { kittyKeyboard: true, win32InputMode: true }, +): { terminal: Terminal; handlers: MockHandlers; getExt: () => Record | undefined } { + const handlers: MockHandlers = { push: [], pop: [] }; + const parser = { + registerCsiHandler( + id: { prefix?: string; final?: string }, + cb: (params: (number | number[])[]) => boolean, + ) { + if (id.prefix === '>' && id.final === 'u') handlers.push.push(cb as PushHandler); + else if (id.prefix === '<' && id.final === 'u') handlers.pop.push(cb); + return { dispose: vi.fn() }; + }, + }; + // A plain property suffices: the arbiter reads and reassigns the whole + // vtExtensions object, so no getter/setter is needed to observe writes. + const options = { vtExtensions: { ...initial } as Record | undefined }; + const terminal = { parser, options } as unknown as Terminal; + return { terminal, handlers, getExt: () => options.vtExtensions }; +} + +describe('attachKeyboardProtocolArbiter', () => { + it('registers one kitty-push and one kitty-pop handler', () => { + const { terminal, handlers } = buildMockTerminal(); + attachKeyboardProtocolArbiter(terminal); + expect(handlers.push).toHaveLength(1); + expect(handlers.pop).toHaveLength(1); + }); + + it('handlers return false so xterm still processes the kitty sequence', () => { + const { terminal, handlers } = buildMockTerminal(); + attachKeyboardProtocolArbiter(terminal); + expect(handlers.push[0]()).toBe(false); + expect(handlers.pop[0]([])).toBe(false); + }); + + it('disables win32-input-mode on kitty push, preserving kittyKeyboard', () => { + const { terminal, handlers, getExt } = buildMockTerminal({ kittyKeyboard: true, win32InputMode: true }); + attachKeyboardProtocolArbiter(terminal); + + handlers.push[0](); + + expect(getExt()).toEqual({ kittyKeyboard: true, win32InputMode: false }); + }); + + it('re-enables win32-input-mode on kitty pop', () => { + const { terminal, handlers, getExt } = buildMockTerminal({ kittyKeyboard: true, win32InputMode: true }); + attachKeyboardProtocolArbiter(terminal); + + handlers.push[0](); + expect(getExt()).toMatchObject({ win32InputMode: false }); + + handlers.pop[0]([]); + expect(getExt()).toEqual({ kittyKeyboard: true, win32InputMode: true }); + }); + + it('keeps win32-input-mode disabled when an inner kitty consumer pops but an outer one is still active', () => { + const { terminal, handlers, getExt } = buildMockTerminal({ kittyKeyboard: true, win32InputMode: true }); + attachKeyboardProtocolArbiter(terminal); + + handlers.push[0](); + handlers.push[0](); + expect(getExt()).toMatchObject({ win32InputMode: false }); + + handlers.pop[0]([]); + expect(getExt()).toMatchObject({ win32InputMode: false }); + + handlers.pop[0]([]); + expect(getExt()).toMatchObject({ win32InputMode: true }); + }); + + it('honors the pop-count parameter on `CSI < N u`', () => { + const { terminal, handlers, getExt } = buildMockTerminal({ kittyKeyboard: true, win32InputMode: true }); + attachKeyboardProtocolArbiter(terminal); + + handlers.push[0](); + handlers.push[0](); + handlers.pop[0]([2]); + + expect(getExt()).toMatchObject({ win32InputMode: true }); + }); + + it('clamps depth at zero so a stray pop without a matching push is a no-op', () => { + const { terminal, handlers, getExt } = buildMockTerminal({ kittyKeyboard: true, win32InputMode: true }); + attachKeyboardProtocolArbiter(terminal); + + handlers.pop[0]([]); + expect(getExt()).toMatchObject({ win32InputMode: true }); + + handlers.push[0](); + expect(getExt()).toMatchObject({ win32InputMode: false }); + + handlers.pop[0]([]); + expect(getExt()).toMatchObject({ win32InputMode: true }); + }); + + it('dispose tears down both handlers', () => { + const disposables: Array<{ dispose: ReturnType }> = []; + const terminal = { + parser: { + registerCsiHandler() { + const d = { dispose: vi.fn() }; + disposables.push(d); + return d; + }, + }, + options: { vtExtensions: { kittyKeyboard: true, win32InputMode: true } }, + } as unknown as Terminal; + + const arbiter = attachKeyboardProtocolArbiter(terminal); + arbiter.dispose(); + + expect(disposables).toHaveLength(2); + expect(disposables[0].dispose).toHaveBeenCalledOnce(); + expect(disposables[1].dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/src/lib/keyboard-protocol-arbiter.ts b/lib/src/lib/keyboard-protocol-arbiter.ts new file mode 100644 index 0000000..917e8dd --- /dev/null +++ b/lib/src/lib/keyboard-protocol-arbiter.ts @@ -0,0 +1,62 @@ +import type { Terminal, IDisposable } from '@xterm/xterm'; + +/** + * Arbitrate between the kitty keyboard protocol and win32-input-mode on Windows. + * + * Dormouse advertises both (`vtExtensions: { kittyKeyboard, win32InputMode }`), + * but xterm.js treats them as mutually exclusive: `useWin32InputMode` is checked + * first in `evaluateKeyDown`, so whenever win32-input-mode is active it wins for + * every keypress and the kitty protocol is never consulted. win32-input-mode + * becomes active as soon as the pane receives `CSI ? 9001 h`, which ConPTY's + * conhost emits proactively — so on Windows it would clobber kitty for *every* + * app, including kitty-based TUIs like Claude Code that rely on kitty's + * Shift+Enter disambiguation. + * + * Both `useWin32InputMode` and `useKitty` re-read the live `vtExtensions` option + * on every keypress, so we keep them from colliding by toggling the option: + * win32-input-mode starts enabled (Console-API TUIs like Codex need it), and the + * moment a foreground app pushes the kitty protocol (`CSI > … u`) we disable it + * for the pane so kitty wins. On kitty pop (`CSI < … u`) we restore it. Apps that + * never touch kitty (Codex) keep win32-input-mode the whole time. + * + * Only meaningful on Windows — elsewhere `win32InputMode` is never advertised, so + * kitty already wins unconditionally and this arbiter is not attached. + */ +export function attachKeyboardProtocolArbiter(terminal: Terminal): IDisposable { + const setWin32InputMode = (enabled: boolean) => { + const ext = terminal.options.vtExtensions; + if (!!ext?.win32InputMode === enabled) return; + // Reassign the whole object — nested mutation would not trip the option + // setter. Spreading preserves kittyKeyboard (and any other extensions). + terminal.options.vtExtensions = { ...ext, win32InputMode: enabled }; + }; + + // We track the push/pop stack ops only — the set-flags form (`CSI = … u`) is + // not observed, since the kitty TUIs we care about (Claude Code) enable the + // protocol via push. Return false so xterm still processes the sequence. + // + // Count nested pushes so a TUI nested inside a kitty consumer (e.g. another + // kitty-using app launched from within Claude Code) doesn't re-enable + // win32-input-mode on inner-exit while the outer kitty consumer is still on + // the stack. `CSI < N u` pops N entries (default 1), so honor the param. + let kittyDepth = 0; + const onKittyPush = terminal.parser.registerCsiHandler({ prefix: '>', final: 'u' }, () => { + kittyDepth++; + setWin32InputMode(false); + return false; + }); + const onKittyPop = terminal.parser.registerCsiHandler({ prefix: '<', final: 'u' }, (params) => { + const raw = params[0]; + const n = (Array.isArray(raw) ? raw[0] : raw) || 1; + kittyDepth = Math.max(0, kittyDepth - n); + if (kittyDepth === 0) setWin32InputMode(true); + return false; + }); + + return { + dispose() { + onKittyPush.dispose(); + onKittyPop.dispose(); + }, + }; +} diff --git a/lib/src/lib/platform/index.ts b/lib/src/lib/platform/index.ts index 3b0a544..ae40e02 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 to advertise win32-input-mode so + * Console-API TUIs behind ConPTY (e.g. Codex) receive faithful key events. + */ +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/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts index 77e17c7..5b790b9 100644 --- a/lib/src/lib/shell-escape.test.ts +++ b/lib/src/lib/shell-escape.test.ts @@ -88,39 +88,32 @@ describe('shellEscapeWindows', () => { }); describe('shellEscapePath OS dispatch', () => { - const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); - beforeEach(() => { vi.resetModules(); }); afterEach(() => { - if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); vi.resetModules(); vi.doUnmock('./platform'); }); - async function importShellEscape(opts: { isMac: boolean; platform: string }) { - vi.doMock('./platform', () => ({ IS_MAC: opts.isMac })); - Object.defineProperty(globalThis, 'navigator', { - value: { platform: opts.platform, userAgent: opts.platform }, - configurable: true, - }); + async function importShellEscape(opts: { isMac: boolean; isWindows: boolean }) { + vi.doMock('./platform', () => ({ IS_MAC: opts.isMac, IS_WINDOWS: opts.isWindows })); return import('./shell-escape'); } it('uses posix escape on macOS', async () => { - const { shellEscapePath } = await importShellEscape({ isMac: true, platform: 'MacIntel' }); + const { shellEscapePath } = await importShellEscape({ isMac: true, isWindows: false }); expect(shellEscapePath('a b.png')).toBe('a\\ b.png'); }); it('uses posix escape on Linux', async () => { - const { shellEscapePath } = await importShellEscape({ isMac: false, platform: 'Linux x86_64' }); + const { shellEscapePath } = await importShellEscape({ isMac: false, isWindows: false }); expect(shellEscapePath('a b.png')).toBe('a\\ b.png'); }); it('uses windows escape on Windows', async () => { - const { shellEscapePath } = await importShellEscape({ isMac: false, platform: 'Win32' }); + const { shellEscapePath } = await importShellEscape({ isMac: false, isWindows: true }); expect(shellEscapePath('a b.png')).toBe(`"a b.png"`); }); }); diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index b2c5e3d..3845583 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -1,11 +1,4 @@ -import { IS_MAC } from './platform'; - -const IS_WINDOWS: boolean = (() => { - if (typeof navigator === 'undefined') return false; - const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; - const p = (nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? '').toLowerCase(); - return p.includes('win'); -})(); +import { IS_MAC, IS_WINDOWS } from './platform'; // Matches macOS Terminal's drag-and-drop format: backslash-escape each shell // metacharacter instead of wrapping in quotes. TUIs like `claude` recognize diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 76f254b..158dcb8 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,9 +1,10 @@ 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 { attachKeyboardProtocolArbiter } from './keyboard-protocol-arbiter'; import { bumpRenderTick, getMouseSelectionState, @@ -105,7 +106,14 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi fontFamily: editorFontFamily, cursorBlink: true, theme, - vtExtensions: { kittyKeyboard: true }, + // kittyKeyboard disambiguates Shift+Enter from Enter for TUIs that read + // raw VT (Claude Code everywhere; Codex on macOS/Linux). win32InputMode + // covers Windows TUIs that read via the Console API behind ConPTY (Codex), + // which can't negotiate the kitty protocol there: when conhost enables it + // (CSI ? 9001 h), xterm sends faithful Win32 INPUT_RECORD key events so + // Shift+Enter and Ctrl+J reach the app intact. Both are opt-in/negotiated, + // so they coexist — each program turns on whichever it understands. + vtExtensions: { kittyKeyboard: true, win32InputMode: IS_WINDOWS }, linkHandler: { activate: (event, uri, range) => { event.preventDefault(); @@ -238,6 +246,9 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}): const disposePty = wirePtyEvents(id, terminal); const disposeXterm = wireXtermHandlers(id, terminal, selectionBaselineRef); const mouseModeObserver = attachMouseModeObserver(id, terminal); + // Windows-only: keep win32-input-mode from clobbering kitty-protocol TUIs. + // Off-Windows win32-input-mode is never advertised, so kitty already wins. + const keyboardProtocolArbiter = IS_WINDOWS ? attachKeyboardProtocolArbiter(terminal) : null; const cleanupMouseRouter = attachTerminalMouseRouter({ id, terminal, @@ -252,6 +263,7 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}): disposePty(); disposeXterm(); mouseModeObserver.dispose(); + keyboardProtocolArbiter?.dispose(); cleanupMouseRouter(); };