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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [Unreleased]

### Changed

- **`attachCustomKeyEventHandler` no longer calls `preventDefault()` for you.**
A handler that returns `true` now takes the event away from the terminal
completely: it is not encoded, and the browser default is left untouched for
the handler to decide on.

Previously there was no way to let a browser-reserved chord through — a
handler returning `true` had the default blocked for it, and returning
`false` ran the encoder path, which blocks it too. A page embedding the
terminal therefore could not keep Cmd+R (reload), Cmd+L (address bar) or the
zoom chords working while the terminal had focus.

**Migration:** a handler that returns `true` and relied on the terminal to
suppress the default must now call `event.preventDefault()` itself. Handlers
that return `false` are unaffected.

---

## [0.4.0] — 2026-05-24

This release is a major feature expansion maintained by the
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,36 @@ If you need byte-for-byte xterm.js behavior for a specific key (e.g. Shift+Enter
term.attachCustomKeyEventHandler((e) => {
if (e.key === 'Enter' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
term.input('\r', true); // fires onData with '\r'
return true; // suppress the default encoder path
e.preventDefault(); // the terminal no longer does this for you
return true; // skip the default encoder path
}
return false;
});
```

### Browser-reserved shortcuts

Returning `true` takes the key away from the terminal completely — it is not
encoded, and `preventDefault()` is **not** called on your behalf. That is what
lets an embedding page keep browser chords working while the terminal has
focus; without it, a focused terminal swallows Cmd+R, Cmd+L and friends.

```ts
const isMac = navigator.userAgent.includes('Mac');

// Copy and paste are handled by the terminal itself (after this handler runs),
// so they must not be handed back to the browser here.
const TERMINAL_OWNED = new Set(['KeyC', 'KeyV']);

term.attachCustomKeyEventHandler((e) => {
if (TERMINAL_OWNED.has(e.code)) return false;

// Let the browser reload, focus the address bar, zoom, and so on. On macOS
// no Cmd chord has a PTY meaning; elsewhere plain Ctrl+R belongs to the
// shell (reverse-i-search), so only the Shift variant is handed back.
const reserved = isMac ? e.metaKey : e.ctrlKey && e.shiftKey;
if (reserved) {
return true; // consumed by "do nothing" — the browser default survives
}
return false;
});
Expand Down
64 changes: 64 additions & 0 deletions lib/input-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,70 @@ describe('InputHandler', () => {
});
});

describe('Custom Key Event Handler', () => {
test('a consumed key is not encoded and its browser default is left alone', () => {
new InputHandler(
ghostty,
container as any,
(data) => dataReceived.push(data),
() => {
bellCalled = true;
},
undefined,
() => true
);

// Cmd+R: the page's reload chord. The handler claims it, so the terminal
// must neither send bytes to the PTY nor block the browser's reload.
const event = createKeyEvent('KeyR', 'r', { meta: true });
simulateKey(container, event);

expect(dataReceived).toEqual([]);
expect(event.preventDefault).not.toHaveBeenCalled();
});

test('a consumed key can still be suppressed by the handler itself', () => {
new InputHandler(
ghostty,
container as any,
(data) => dataReceived.push(data),
() => {
bellCalled = true;
},
undefined,
(e) => {
e.preventDefault();
return true;
}
);

const event = createKeyEvent('KeyK', 'k', { meta: true });
simulateKey(container, event);

expect(dataReceived).toEqual([]);
expect(event.preventDefault).toHaveBeenCalled();
});

test('a declined key falls through to the encoder and is suppressed', () => {
new InputHandler(
ghostty,
container as any,
(data) => dataReceived.push(data),
() => {
bellCalled = true;
},
undefined,
() => false
);

const event = createKeyEvent('KeyA', 'a');
simulateKey(container, event);

expect(dataReceived).toEqual(['a']);
expect(event.preventDefault).toHaveBeenCalled();
});
});

describe('Unknown Keys', () => {
test('ignores unmapped keys', () => {
const handler = new InputHandler(
Expand Down
26 changes: 22 additions & 4 deletions lib/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
*
* Limitations:
* - Does not handle IME/composition events (CJK input) - to be added later
* - Captures all keyboard input (preventDefault on everything)
* - Captures all keyboard input it encodes (preventDefault on everything
* that reaches the encoder). A custom key event handler can opt a chord
* out of that, including out of preventDefault — see setCustomKeyEventHandler.
*/

import type { Ghostty, KeyEncoder } from './ghostty';
Expand Down Expand Up @@ -261,7 +263,15 @@ export class InputHandler {
}

/**
* Set custom key event handler (for runtime updates)
* Set custom key event handler (for runtime updates).
*
* The handler returns whether it consumed the event. Returning `true` takes
* the key away from the terminal entirely: no encoding, and no
* `preventDefault()` — so the handler decides what happens to the browser
* default. Call `event.preventDefault()` inside the handler to suppress it,
* or leave it alone to let a browser-reserved chord (Cmd+R, Cmd+L) work.
* Returning `false` runs the normal encoder path, which suppresses the
* default as before.
*/
setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void {
this.customKeyEventHandler = handler;
Expand Down Expand Up @@ -406,8 +416,16 @@ export class InputHandler {
if (this.customKeyEventHandler) {
const handled = this.customKeyEventHandler(event);
if (handled) {
// Custom handler consumed the event
event.preventDefault();
// The consumer owns this event: the terminal neither encodes it nor
// touches the browser default. Handlers that want the default
// suppressed call event.preventDefault() themselves.
//
// Calling preventDefault() here unconditionally left consumers with no
// way to pass a browser-reserved chord through: returning true blocked
// the default, and returning false ran the encoder path, which blocks
// it too. A page embedding the terminal could therefore never keep
// Cmd+R (reload) or Cmd+L (focus the address bar) working while the
// terminal had focus.
return;
}
}
Expand Down
9 changes: 9 additions & 0 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,15 @@ export class Terminal extends TerminalCore {
// Custom Event Handlers
// ==========================================================================

/**
* Attach a handler that sees every keydown before the terminal encodes it.
*
* Return `true` to consume the event: the terminal will not encode it and
* will not call `preventDefault()`, leaving the browser default to the
* handler. Suppress it with `event.preventDefault()`, or leave it alone to
* let browser-reserved chords (Cmd+R, Cmd+L, zoom) keep working while the
* terminal has focus. Return `false` for normal terminal handling.
*/
public attachCustomKeyEventHandler(
customKeyEventHandler: (event: KeyboardEvent) => boolean
): void {
Expand Down
Loading