From 2d78e2d53cc00f2f9131f77a654779cafc33f699 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 21:10:39 -0700 Subject: [PATCH 1/7] Enable win32-input-mode on Windows so Codex gets faithful key events Codex (and other TUIs that read keyboard input via the Windows Console API behind ConPTY) never receives a distinct Shift+Enter or Ctrl+J in Dormouse: it can't negotiate the kitty keyboard protocol on Windows (crossterm's PushKeyboardEnhancementFlags fails there), and xterm.js otherwise sends only legacy bytes, which ConPTY's byte->INPUT_RECORD translation strips of virtual-key/modifier fidelity. Plain Enter still maps cleanly (CR), so the pane submits instead of inserting a newline. Advertise win32-input-mode (gated to Windows) the same way Windows Terminal does. xterm.js answers the program's CSI ? 9001 h and then emits Win32 INPUT_RECORD key records, so Shift+Enter and Ctrl+J reach the app intact. It's negotiated per program, so it coexists with the kitty protocol that already covers Claude Code and macOS/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++++ docs/specs/terminal-escapes.md | 3 ++- lib/src/lib/platform/index.ts | 6 ++++++ lib/src/lib/terminal-lifecycle.ts | 11 +++++++++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f14202..da42d041 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. + + ## [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 603fc6e5..5e0f27e3 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. The kitty protocol still covers macOS/Linux and raw-VT apps; the two negotiate independently. | `lib/src/lib/terminal-lifecycle.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/platform/index.ts b/lib/src/lib/platform/index.ts index 3b0a544b..ae40e02f 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/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 76f254b5..f8eb67a0 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 { @@ -105,7 +105,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(); From 5d026651c480e1739a32b3e2fd329cd8c6699063 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 21:21:41 -0700 Subject: [PATCH 2/7] Arbitrate kitty vs win32-input-mode per pane so both Claude and Codex work Enabling win32-input-mode regressed Shift+Enter for Claude Code on Windows (it submitted instead of inserting a newline). xterm.js treats the two protocols as mutually exclusive: evaluateKeyDown checks useWin32InputMode first, so whenever win32-input-mode is active it wins for every keypress and the kitty protocol is never consulted. ConPTY's conhost enables win32-input-mode proactively (CSI ? 9001 h), so once the option is advertised it clobbers kitty for every app in the pane, including kitty-based TUIs like Claude that rely on kitty's Shift+Enter disambiguation. Both useWin32InputMode and useKitty re-read the live vtExtensions option each keypress, and only "cols"/"rows" are constructor-only, so the option is runtime-mutable. Add a Windows-only per-pane arbiter that starts with win32-input-mode on (Codex needs it) and toggles it off the moment an app pushes the kitty protocol (CSI > ... u), restoring it on pop (CSI < ... u). Apps that never touch kitty (Codex) keep win32-input-mode; kitty apps (Claude) get kitty. Off-Windows the option is never advertised, so the arbiter is not attached. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/terminal-escapes.md | 2 +- lib/src/lib/keyboard-protocol-arbiter.test.ts | 97 +++++++++++++++++++ lib/src/lib/keyboard-protocol-arbiter.ts | 50 ++++++++++ lib/src/lib/terminal-lifecycle.ts | 5 + 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 lib/src/lib/keyboard-protocol-arbiter.test.ts create mode 100644 lib/src/lib/keyboard-protocol-arbiter.ts diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 5e0f27e3..b5f3967f 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -86,7 +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. The kitty protocol still covers macOS/Linux and raw-VT apps; the two negotiate independently. | `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 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 00000000..223727f9 --- /dev/null +++ b/lib/src/lib/keyboard-protocol-arbiter.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; +import { attachKeyboardProtocolArbiter } from './keyboard-protocol-arbiter'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface MockHandlers { + push: Array<() => boolean>; + pop: Array<() => boolean>; +} + +function buildMockTerminal( + initial: { kittyKeyboard?: boolean; win32InputMode?: boolean } = { kittyKeyboard: true, win32InputMode: true }, +): { terminal: Terminal; handlers: MockHandlers; getExt: () => Record | undefined } { + const handlers: MockHandlers = { push: [], pop: [] }; + let vtExtensions: Record | undefined = { ...initial }; + const parser = { + registerCsiHandler(id: { prefix?: string; final?: string }, cb: () => boolean) { + if (id.prefix === '>' && id.final === 'u') handlers.push.push(cb); + else if (id.prefix === '<' && id.final === 'u') handlers.pop.push(cb); + return { dispose: vi.fn() }; + }, + }; + const terminal = { + parser, + get options() { + return { + get vtExtensions() { + return vtExtensions; + }, + set vtExtensions(value: Record | undefined) { + vtExtensions = value; + }, + }; + }, + } as unknown as Terminal; + return { terminal, handlers, getExt: () => 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('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 00000000..bd45ed9f --- /dev/null +++ b/lib/src/lib/keyboard-protocol-arbiter.ts @@ -0,0 +1,50 @@ +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 }; + }; + + // Return false so xterm still processes the sequence; we only observe. + const onKittyPush = terminal.parser.registerCsiHandler({ prefix: '>', final: 'u' }, () => { + setWin32InputMode(false); + return false; + }); + const onKittyPop = terminal.parser.registerCsiHandler({ prefix: '<', final: 'u' }, () => { + setWin32InputMode(true); + return false; + }); + + return { + dispose() { + onKittyPush.dispose(); + onKittyPop.dispose(); + }, + }; +} diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index f8eb67a0..158dcb8b 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -4,6 +4,7 @@ import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; 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, @@ -245,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, @@ -259,6 +263,7 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}): disposePty(); disposeXterm(); mouseModeObserver.dispose(); + keyboardProtocolArbiter?.dispose(); cleanupMouseRouter(); }; From 942462b9109910eb1a3940fcbb21efe2ced6d8c0 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 22:04:21 -0700 Subject: [PATCH 3/7] Simplify arbiter test mock and document push/pop-only scope Replace the nested getter-of-getter vtExtensions mock with a plain property object (the arbiter reassigns the whole object, so no accessor is needed to observe writes), and note that the arbiter intentionally tracks only the kitty push/pop stack ops, not the set-flags form. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/keyboard-protocol-arbiter.test.ts | 20 +++++-------------- lib/src/lib/keyboard-protocol-arbiter.ts | 4 +++- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/lib/src/lib/keyboard-protocol-arbiter.test.ts b/lib/src/lib/keyboard-protocol-arbiter.test.ts index 223727f9..a2a304d4 100644 --- a/lib/src/lib/keyboard-protocol-arbiter.test.ts +++ b/lib/src/lib/keyboard-protocol-arbiter.test.ts @@ -15,7 +15,6 @@ function buildMockTerminal( initial: { kittyKeyboard?: boolean; win32InputMode?: boolean } = { kittyKeyboard: true, win32InputMode: true }, ): { terminal: Terminal; handlers: MockHandlers; getExt: () => Record | undefined } { const handlers: MockHandlers = { push: [], pop: [] }; - let vtExtensions: Record | undefined = { ...initial }; const parser = { registerCsiHandler(id: { prefix?: string; final?: string }, cb: () => boolean) { if (id.prefix === '>' && id.final === 'u') handlers.push.push(cb); @@ -23,20 +22,11 @@ function buildMockTerminal( return { dispose: vi.fn() }; }, }; - const terminal = { - parser, - get options() { - return { - get vtExtensions() { - return vtExtensions; - }, - set vtExtensions(value: Record | undefined) { - vtExtensions = value; - }, - }; - }, - } as unknown as Terminal; - return { terminal, handlers, getExt: () => vtExtensions }; + // 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', () => { diff --git a/lib/src/lib/keyboard-protocol-arbiter.ts b/lib/src/lib/keyboard-protocol-arbiter.ts index bd45ed9f..3b4a55a1 100644 --- a/lib/src/lib/keyboard-protocol-arbiter.ts +++ b/lib/src/lib/keyboard-protocol-arbiter.ts @@ -31,7 +31,9 @@ export function attachKeyboardProtocolArbiter(terminal: Terminal): IDisposable { terminal.options.vtExtensions = { ...ext, win32InputMode: enabled }; }; - // Return false so xterm still processes the sequence; we only observe. + // 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. const onKittyPush = terminal.parser.registerCsiHandler({ prefix: '>', final: 'u' }, () => { setWin32InputMode(false); return false; From e9eef403050589c30de4ca90e83d814c0d61fdc1 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 22:05:50 -0700 Subject: [PATCH 4/7] DRY IS_WINDOWS: reuse the shared platform constant in shell-escape shell-escape.ts had its own navigator-sniffing IIFE that duplicated the detection now exported from platform/index.ts. Import IS_WINDOWS instead, and drive the dispatch test by mocking the platform booleans directly rather than poking globalThis.navigator to feed the removed IIFE. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/shell-escape.test.ts | 17 +++++------------ lib/src/lib/shell-escape.ts | 9 +-------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts index 77e17c71..5b790b90 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 b2c5e3db..38455831 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 From cccba7f398dcb59cf4a51967aea55b5e8278d5ef Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 22:44:33 -0700 Subject: [PATCH 5/7] Add PR link to CHANGELOG entry Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da42d041..8eafe0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). Release ## [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. +- 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 From a0f698653413fd547fe1bc9d61bb4153b121f395 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 29 May 2026 14:47:39 +0000 Subject: [PATCH 6/7] Refcount nested kitty push/pop in the arbiter If a kitty-using TUI is nested inside another (e.g. a kitty consumer launched from within Claude Code), the first pop on inner exit would re-enable win32-input-mode while the outer kitty consumer was still on the stack, breaking its Shift+Enter disambiguation until the outer also popped. Track a depth counter and only restore win32-input-mode when depth returns to zero. Honor the `CSI < N u` pop-count parameter, and clamp at zero so a stray pop without a matching push is a no-op. --- lib/src/lib/keyboard-protocol-arbiter.test.ts | 58 +++++++++++++++++-- lib/src/lib/keyboard-protocol-arbiter.ts | 14 ++++- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/lib/src/lib/keyboard-protocol-arbiter.test.ts b/lib/src/lib/keyboard-protocol-arbiter.test.ts index a2a304d4..136c2f70 100644 --- a/lib/src/lib/keyboard-protocol-arbiter.test.ts +++ b/lib/src/lib/keyboard-protocol-arbiter.test.ts @@ -6,9 +6,12 @@ afterEach(() => { vi.restoreAllMocks(); }); +type PushHandler = () => boolean; +type PopHandler = (params: (number | number[])[]) => boolean; + interface MockHandlers { - push: Array<() => boolean>; - pop: Array<() => boolean>; + push: Array; + pop: Array; } function buildMockTerminal( @@ -16,8 +19,11 @@ function buildMockTerminal( ): { terminal: Terminal; handlers: MockHandlers; getExt: () => Record | undefined } { const handlers: MockHandlers = { push: [], pop: [] }; const parser = { - registerCsiHandler(id: { prefix?: string; final?: string }, cb: () => boolean) { - if (id.prefix === '>' && id.final === 'u') handlers.push.push(cb); + 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() }; }, @@ -41,7 +47,7 @@ describe('attachKeyboardProtocolArbiter', () => { const { terminal, handlers } = buildMockTerminal(); attachKeyboardProtocolArbiter(terminal); expect(handlers.push[0]()).toBe(false); - expect(handlers.pop[0]()).toBe(false); + expect(handlers.pop[0]([])).toBe(false); }); it('disables win32-input-mode on kitty push, preserving kittyKeyboard', () => { @@ -60,10 +66,50 @@ describe('attachKeyboardProtocolArbiter', () => { handlers.push[0](); expect(getExt()).toMatchObject({ win32InputMode: false }); - handlers.pop[0](); + 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 = { diff --git a/lib/src/lib/keyboard-protocol-arbiter.ts b/lib/src/lib/keyboard-protocol-arbiter.ts index 3b4a55a1..917e8dd4 100644 --- a/lib/src/lib/keyboard-protocol-arbiter.ts +++ b/lib/src/lib/keyboard-protocol-arbiter.ts @@ -34,12 +34,22 @@ export function attachKeyboardProtocolArbiter(terminal: Terminal): IDisposable { // 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' }, () => { - setWin32InputMode(true); + 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; }); From 1f714698dcd7090f562c62b1da0b4d684fa5d75b Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 29 May 2026 14:47:39 +0000 Subject: [PATCH 7/7] Expose IS_WINDOWS on the clipboard test's platform mock shell-escape.ts now imports IS_WINDOWS from ./platform after the DRY pass; the doPaste tests mock ./platform and were missing the new export, so shellEscapePath threw "No \"IS_WINDOWS\" export is defined" when the file-refs and image-fallthrough tests reached pasteFilePaths. --- lib/src/lib/clipboard.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts index 239c7a25..35b4fb2d 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,