Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
3 changes: 2 additions & 1 deletion docs/specs/terminal-escapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({

vi.mock('./platform', () => ({
IS_MAC: false,
IS_WINDOWS: false,
getPlatform: () => ({
readClipboardFilePaths: mocks.readClipboardFilePaths,
readClipboardImageAsFilePath: mocks.readClipboardImageAsFilePath,
Expand Down
133 changes: 133 additions & 0 deletions lib/src/lib/keyboard-protocol-arbiter.test.ts
Original file line number Diff line number Diff line change
@@ -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<PushHandler>;
pop: Array<PopHandler>;
}

function buildMockTerminal(
initial: { kittyKeyboard?: boolean; win32InputMode?: boolean } = { kittyKeyboard: true, win32InputMode: true },
): { terminal: Terminal; handlers: MockHandlers; getExt: () => Record<string, unknown> | 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<string, unknown> | 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<typeof vi.fn> }> = [];
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();
});
});
62 changes: 62 additions & 0 deletions lib/src/lib/keyboard-protocol-arbiter.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
6 changes: 6 additions & 0 deletions lib/src/lib/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const PLATFORM_STRING: string = (() => {
*/
export const IS_MAC: boolean = /Mac|iPhone|iPad/i.test(PLATFORM_STRING);

/**
* True when running on Windows. Used 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). */
Expand Down
17 changes: 5 additions & 12 deletions lib/src/lib/shell-escape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`);
});
});
9 changes: 1 addition & 8 deletions lib/src/lib/shell-escape.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 14 additions & 2 deletions lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -252,6 +263,7 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}):
disposePty();
disposeXterm();
mouseModeObserver.dispose();
keyboardProtocolArbiter?.dispose();
cleanupMouseRouter();
};

Expand Down
Loading