authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-04 12:51:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 12:51:36+02:00
log553867262cf22f982622df3eb7f3174f62b4ba2b
tree1156016af1ad40e379e2e31ea9bea40463dbb5db
parent11639bf4391d9d49e48052bae755f748cbd22ff2
signature Signed by PGP key B5690EEEBB952194

fix(terminal): protect held Ctrl/Cmd+W close shortcut (#5322)


6 files changed, 195 insertions(+), 2 deletions(-)

apps/desktop/src/window/DesktopWindow.test.ts+45
......@@ -438,6 +438,51 @@ describe("DesktopWindow", () => {
438438 }),
439439 );
440440
441 it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () =>
442 Effect.gen(function* () {
443 const fakeWindow = makeFakeBrowserWindow();
444 const createCount = yield* Ref.make(0);
445 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
446 const layer = makeTestLayer({
447 window: fakeWindow.window,
448 createCount,
449 mainWindow,
450 });
451
452 yield* Effect.gen(function* () {
453 const desktopWindow = yield* DesktopWindow.DesktopWindow;
454 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
455
456 const beforeInput = fakeWindow.webContentsListeners.get("before-input-event");
457 if (!beforeInput) {
458 return yield* Effect.die("before-input-event listener was not registered");
459 }
460
461 let prevented = false;
462 const event = { preventDefault: () => (prevented = true) };
463 const input = {
464 type: "keyDown",
465 isAutoRepeat: true,
466 key: "W",
467 meta: true,
468 control: false,
469 alt: false,
470 shift: false,
471 };
472 beforeInput(event, input);
473 assert.isTrue(prevented);
474
475 prevented = false;
476 beforeInput(event, { ...input, isAutoRepeat: false });
477 assert.isFalse(prevented);
478
479 prevented = false;
480 beforeInput(event, { ...input, meta: false });
481 assert.isFalse(prevented);
482 }).pipe(Effect.provide(layer));
483 }),
484 );
485
441486 it.effect("uses the persisted main window bounds when opening the window", () =>
442487 Effect.gen(function* () {
443488 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+12
......@@ -514,6 +514,18 @@ export const make = Effect.gen(function* () {
514514 }
515515 });
516516
517 // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the
518 // close-terminal shortcut can outlive the terminal that handled its first
519 // press, so reject repeats before they reach the native window accelerator.
520 // Deliberate presses still flow through the renderer or native menu.
521 window.webContents.on("before-input-event", (event, input) => {
522 if (input.type !== "keyDown" || !input.isAutoRepeat) return;
523 const modifier = environment.platform === "darwin" ? input.meta : input.control;
524 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
525 event.preventDefault();
526 }
527 });
528
517529 window.on("page-title-updated", (event) => {
518530 event.preventDefault();
519531 window.setTitle(environment.displayName);
apps/web/src/components/ChatView.tsx+5
......@@ -171,6 +171,7 @@ import { useNowMinute } from "../hooks/useNowMinute";
171171import { useNewThreadHandler } from "../hooks/useHandleNewThread";
172172import { resolveAppModelSelectionForInstance } from "../modelSelection";
173173import { getTerminalFocusOwner } from "../lib/terminalFocus";
174import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
174175import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
175176import {
176177 deriveLogicalProjectKeyFromSettings,
......@@ -4354,6 +4355,10 @@ function ChatViewContent(props: ChatViewProps) {
43544355
43554356 useEffect(() => {
43564357 const handler = (event: globalThis.KeyboardEvent) => {
4358 if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
4359 event.stopPropagation();
4360 return;
4361 }
43574362 if (!activeThreadId || isCommandPaletteOpen()) {
43584363 return;
43594364 }
apps/web/src/components/ThreadTerminalDrawer.tsx+4-2
......@@ -43,7 +43,6 @@ import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-lin
4343import {
4444 isDiffToggleShortcut,
4545 isTerminalClearShortcut,
46 isTerminalCloseShortcut,
4746 isTerminalNewShortcut,
4847 isTerminalSplitShortcut,
4948 isTerminalSplitVerticalShortcut,
......@@ -63,6 +62,7 @@ import { previewEnvironment } from "../state/preview";
6362import { terminalEnvironment } from "../state/terminal";
6463import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
6564import { useAtomCommand } from "../state/use-atom-command";
65import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
6666
6767const MIN_DRAWER_HEIGHT = 180;
6868const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -527,12 +527,14 @@ export function TerminalViewport({
527527 function handleBeforeKey(event: KeyboardEvent): boolean {
528528 const currentKeybindings = keybindingsRef.current;
529529 const options = { context: { terminalFocus: true, terminalOpen: true } };
530 if (preventTerminalCloseShortcut(event, currentKeybindings)) {
531 return false;
532 }
530533 if (
531534 isTerminalToggleShortcut(event, currentKeybindings, options) ||
532535 isTerminalSplitShortcut(event, currentKeybindings, options) ||
533536 isTerminalSplitVerticalShortcut(event, currentKeybindings, options) ||
534537 isTerminalNewShortcut(event, currentKeybindings, options) ||
535 isTerminalCloseShortcut(event, currentKeybindings, options) ||
536538 isDiffToggleShortcut(event, currentKeybindings, options)
537539 ) {
538540 return false;
apps/web/src/lib/terminalCloseShortcut.test.ts created+93
......@@ -0,0 +1,93 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
4
5import {
6 preventRepeatedTerminalCloseShortcut,
7 preventTerminalCloseShortcut,
8 type TerminalCloseShortcutEvent,
9} from "./terminalCloseShortcut";
10
11const keybindings = [
12 {
13 command: "terminal.close",
14 shortcut: {
15 key: "w",
16 metaKey: false,
17 ctrlKey: false,
18 shiftKey: false,
19 altKey: false,
20 modKey: true,
21 },
22 whenAst: { type: "identifier", name: "terminalFocus" },
23 },
24] satisfies ResolvedKeybindingsConfig;
25
26type KeyboardEventOverrides = Partial<
27 Pick<
28 TerminalCloseShortcutEvent,
29 "key" | "code" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey" | "repeat"
30 >
31>;
32
33function keyboardEvent(
34 overrides: KeyboardEventOverrides = {},
35): TerminalCloseShortcutEvent & { readonly defaultPrevented: boolean } {
36 let defaultPrevented = false;
37 return {
38 key: "w",
39 code: "KeyW",
40 metaKey: false,
41 ctrlKey: true,
42 shiftKey: false,
43 altKey: false,
44 repeat: false,
45 ...overrides,
46 preventDefault: () => {
47 defaultPrevented = true;
48 },
49 get defaultPrevented() {
50 return defaultPrevented;
51 },
52 };
53}
54
55describe("terminal close shortcut guards", () => {
56 it("prevents the browser default for a deliberate terminal close", () => {
57 const event = keyboardEvent();
58
59 expect(preventTerminalCloseShortcut(event, keybindings, "Linux x86_64")).toBe(true);
60 expect(event.defaultPrevented).toBe(true);
61 });
62
63 it("keeps held close repeats from closing the browser after the last terminal unmounts", () => {
64 const firstPress = keyboardEvent();
65 expect(preventTerminalCloseShortcut(firstPress, keybindings, "Linux x86_64")).toBe(true);
66
67 let browserCloseCount = 0;
68 for (const repeat of [true, true, true]) {
69 const event = keyboardEvent({ repeat });
70 preventRepeatedTerminalCloseShortcut(event, keybindings, "Linux x86_64");
71 if (!event.defaultPrevented) browserCloseCount += 1;
72 }
73
74 expect(browserCloseCount).toBe(0);
75 expect(preventRepeatedTerminalCloseShortcut(keyboardEvent(), keybindings, "Linux x86_64")).toBe(
76 false,
77 );
78 });
79
80 it("leaves a non-repeated window close and unrelated repeats alone", () => {
81 const deliberateWindowClose = keyboardEvent({ repeat: false });
82 const unrelatedRepeat = keyboardEvent({ key: "q", code: "KeyQ", repeat: true });
83
84 expect(
85 preventRepeatedTerminalCloseShortcut(deliberateWindowClose, keybindings, "Linux x86_64"),
86 ).toBe(false);
87 expect(deliberateWindowClose.defaultPrevented).toBe(false);
88 expect(preventRepeatedTerminalCloseShortcut(unrelatedRepeat, keybindings, "Linux x86_64")).toBe(
89 false,
90 );
91 expect(unrelatedRepeat.defaultPrevented).toBe(false);
92 });
93});
apps/web/src/lib/terminalCloseShortcut.ts created+36
......@@ -0,0 +1,36 @@
1import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
2
3import { isTerminalCloseShortcut, type ShortcutEventLike } from "../keybindings";
4
5export interface TerminalCloseShortcutEvent extends ShortcutEventLike {
6 readonly repeat?: boolean;
7 readonly preventDefault: () => void;
8}
9
10function terminalCloseShortcutOptions(platform?: string) {
11 return {
12 ...(platform === undefined ? {} : { platform }),
13 context: { terminalFocus: true, terminalOpen: true },
14 };
15}
16
17export function preventTerminalCloseShortcut(
18 event: TerminalCloseShortcutEvent,
19 keybindings: ResolvedKeybindingsConfig,
20 platform?: string,
21): boolean {
22 if (!isTerminalCloseShortcut(event, keybindings, terminalCloseShortcutOptions(platform))) {
23 return false;
24 }
25 event.preventDefault();
26 return true;
27}
28
29export function preventRepeatedTerminalCloseShortcut(
30 event: TerminalCloseShortcutEvent,
31 keybindings: ResolvedKeybindingsConfig,
32 platform?: string,
33): boolean {
34 if (!event.repeat) return false;
35 return preventTerminalCloseShortcut(event, keybindings, platform);
36}