# Files changed · PR \#13428 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/13428/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): right panel shortcuts on non-Latin layouts and terminal close dialog UX](https://git.cafe/pingdotgg/t3code/pull/13428.md)

Version: 6

Head commit: 7f92a102ed098ad829e0dde6513b0e1a73c2ad2b

Base commit: 2679d279ce4b4879290bc756cf219b10f77e1873

Comparison base: b2b43bef73447c483ceae486890cb79f01c369cb

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/ChatView.tsx

Status: modified; +14; −1

```diff
@@ -683,6 +683,10 @@
   return true;
 }
 
+/**
+ * Determines whether an unhandled single-character keypress should redirect
+ * focus to the thread composer, skipping shortcuts owned by active surfaces.
+ */
 function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
   if (event.isComposing) return false;
   if (event.metaKey || event.ctrlKey || event.altKey) return false;
@@ -695,7 +699,15 @@
   const launcherKeys = document
     .querySelector("[data-surface-launcher-keys]")
     ?.getAttribute("data-surface-launcher-keys");
+  if (launcherKeys) {
+    const layoutKey = event.key.toLowerCase();
+    const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase();
+    const normalizedKeys = launcherKeys.toLowerCase();
+    if (normalizedKeys.includes(layoutKey)) return false;
+    if (letterCode && !/^[a-z]$/.test(layoutKey) && normalizedKeys.includes(letterCode)) {
+      return false;
+    }
-  if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false;
+  }
 
   return true;
 }
@@ -10382,6 +10394,7 @@
         >
           <RightPanelTabs
             mode="sheet"
+            open={rightPanelOpen}
             // Same effective inset as the closed-state titlebar controls
             // (pr-3 in the tab bar plus this pixel equals the absolute
             // right inset plus mr-px), so the cluster does not creep when
```

## apps/web/src/components/ConfirmDialogHost.tsx

Status: modified; +116; −19

```diff
@@ -1,4 +1,11 @@
+import {
+  useEffect,
+  useRef,
+  useState,
+  useSyncExternalStore,
-import { useEffect, useSyncExternalStore } from "react";
+  type KeyboardEvent as ReactKeyboardEvent,
+} from "react";
+import type { ConfirmDialogCheckboxOptions, ConfirmDialogVariant } from "@t3tools/contracts";
 
 import {
   completeConfirmDialogClose,
@@ -17,12 +24,16 @@
   AlertDialogTitle,
 } from "./ui/alert-dialog";
 import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
 
 type ConfirmationCopy = {
   readonly title: string;
   readonly description: string | null;
 };
 
+/**
+ * Splits a multiline confirmation message into a concise title and description.
+ */
 function resolveConfirmDialogCopy(message: string): ConfirmationCopy {
   const normalizedMessage = message.trim();
   const lines = normalizedMessage.split("\n");
@@ -51,6 +62,99 @@
   };
 }
 
+type ConfirmDialogBodyProps = {
+  readonly message: string;
+  readonly variant: ConfirmDialogVariant;
+  readonly checkbox?: ConfirmDialogCheckboxOptions | undefined;
+  readonly onConfirm: () => void;
+};
+
+/**
+ * Dialog content body with its own local checkbox state and keyboard arrow navigation.
+ */
+function ConfirmDialogBody({ message, variant, checkbox, onConfirm }: ConfirmDialogBodyProps) {
+  const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
+  const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
+  const checkboxRef = useRef<HTMLButtonElement | null>(null);
+
+  const [checkboxChecked, setCheckboxChecked] = useState(checkbox?.checked ?? false);
+
+  const copy = resolveConfirmDialogCopy(message);
+
+  const handleKeyDown = (event: ReactKeyboardEvent) => {
+    const target = event.target as HTMLElement | null;
+    if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
+      if (target === confirmButtonRef.current) {
+        event.preventDefault();
+        cancelButtonRef.current?.focus();
+      } else if (target === cancelButtonRef.current) {
+        event.preventDefault();
+        confirmButtonRef.current?.focus();
+      }
+    } else if (event.key === "ArrowUp") {
+      if (
+        checkboxRef.current &&
+        (target === confirmButtonRef.current || target === cancelButtonRef.current)
+      ) {
+        event.preventDefault();
+        checkboxRef.current.focus();
+      }
+    } else if (event.key === "ArrowDown") {
+      if (checkboxRef.current && target === checkboxRef.current) {
+        event.preventDefault();
+        confirmButtonRef.current?.focus();
+      }
+    }
+  };
+
+  return (
+    <AlertDialogPopup initialFocus={confirmButtonRef}>
+      <AlertDialogHeader>
+        <AlertDialogTitle>{copy.title}</AlertDialogTitle>
+        {copy.description ? (
+          <AlertDialogDescription className="whitespace-pre-line">
+            {copy.description}
+          </AlertDialogDescription>
+        ) : null}
+      </AlertDialogHeader>
+      <AlertDialogFooter
+        className={
+          checkbox ? "flex-col-reverse sm:flex-row sm:items-center sm:justify-between" : undefined
+        }
+        onKeyDown={handleKeyDown}
+      >
+        {checkbox ? (
+          <div className="mb-2 sm:mb-0">
+            <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground select-none sm:self-center">
+              <Checkbox
+                ref={checkboxRef}
+                checked={checkboxChecked}
+                onCheckedChange={(checked) => {
+                  const next = checked === true;
+                  setCheckboxChecked(next);
+                  checkbox.onCheckedChange?.(next);
+                }}
+              />
+              <span>{checkbox.label}</span>
+            </label>
+          </div>
+        ) : null}
+        <div className="flex w-full items-center justify-end gap-2 sm:w-auto">
+          <AlertDialogClose ref={cancelButtonRef} render={<Button variant="outline" />}>
+            Cancel
+          </AlertDialogClose>
+          <Button ref={confirmButtonRef} autoFocus variant={variant} onClick={onConfirm}>
+            Confirm
+          </Button>
+        </div>
+      </AlertDialogFooter>
+    </AlertDialogPopup>
+  );
+}
+
+/**
+ * Top-level host for imperative confirmation dialogs across the application.
+ */
 export function ConfirmDialogHost() {
   const state = useSyncExternalStore(
     subscribeConfirmDialog,
@@ -60,11 +164,11 @@
 
   useEffect(() => registerConfirmDialogHost(), []);
 
-  const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message);
-  const confirmVariant = state.status === "idle" ? "default" : state.variant;
   const onCancel = () => respondToConfirmDialog(false);
   const onConfirm = () => respondToConfirmDialog(true);
 
+  const isMounted = state.status === "confirming" || state.status === "closing";
+
   return (
     <AlertDialog
       open={state.status === "confirming"}
@@ -75,22 +179,15 @@
         if (!open) completeConfirmDialogClose();
       }}
     >
+      {isMounted ? (
+        <ConfirmDialogBody
+          key={state.id}
+          message={state.message}
+          variant={state.variant}
+          checkbox={state.checkbox}
+          onConfirm={onConfirm}
+        />
+      ) : null}
-      <AlertDialogPopup>
-        <AlertDialogHeader>
-          <AlertDialogTitle>{copy.title}</AlertDialogTitle>
-          {copy.description ? (
-            <AlertDialogDescription className="whitespace-pre-line">
-              {copy.description}
-            </AlertDialogDescription>
-          ) : null}
-        </AlertDialogHeader>
-        <AlertDialogFooter>
-          <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
-          <Button variant={confirmVariant} onClick={onConfirm}>
-            Confirm
-          </Button>
-        </AlertDialogFooter>
-      </AlertDialogPopup>
     </AlertDialog>
   );
 }
```

## apps/web/src/components/RightPanelTabs.test.tsx

Status: modified; +27; −0

```diff
@@ -173,6 +173,33 @@
     expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]);
   });
 
+  it("matches surface shortcuts by physical key code on non-Latin layouts", () => {
+    expect(
+      surfaceShortcutActionForKey(actions, {
+        ...shortcutEvent("и"),
+        code: "KeyB",
+      }),
+    ).toBe(actions[0]);
+  });
+
+  it("does not match by physical code when the layout key is a different Latin letter", () => {
+    expect(
+      surfaceShortcutActionForKey(actions, {
+        ...shortcutEvent("d"),
+        code: "KeyB",
+      }),
+    ).toBeNull();
+  });
+
+  it("does not activate shortcuts on dead key events", () => {
+    expect(
+      surfaceShortcutActionForKey(actions, {
+        ...shortcutEvent("Dead"),
+        code: "KeyB",
+      }),
+    ).toBeNull();
+  });
+
   it("does not activate unavailable surfaces", () => {
     expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull();
   });
```

## apps/web/src/components/RightPanelTabs.tsx

Status: modified; +40; −13

```diff
@@ -246,17 +246,33 @@
 type SurfaceShortcutEvent = Pick<
   KeyboardEvent,
   "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey"
->;
+> & { readonly code?: string };
 
+/**
+ * Resolves the surface shortcut action matching a keyboard event, checking both
+ * the active keyboard layout character and physical key code fallbacks for non-Latin layouts.
+ */
 export function surfaceShortcutActionForKey<
   const Action extends { available: boolean; shortcut: string },
 >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null {
   if (event.defaultPrevented || event.isComposing) return null;
   if (event.metaKey || event.ctrlKey || event.altKey) return null;
+  const layoutKey = event.key.toLowerCase();
+  const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase();
   return (
+    actions.find((action) => {
+      if (!action.available) return false;
+      const targetShortcut = action.shortcut.toLowerCase();
+      if (targetShortcut === layoutKey) return true;
+      if (
+        letterCode &&
+        layoutKey.length === 1 &&
+        !/^[a-z]$/.test(layoutKey) &&
+        targetShortcut === letterCode
+      )
+        return true;
+      return false;
+    }) ?? null
-    actions.find(
-      (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(),
-    ) ?? null
   );
 }
 
@@ -314,8 +330,11 @@
  * outside a typing context, and arrows plus Enter work while the launcher is
  * focused. The highlight only appears on hover or arrow use. Unavailable
  * surfaces stay visible with a one-line reason.
+ *
+ * Empty launcher state rendered inside the right panel when no surface is active.
  */
 function RightPanelEmptyState(props: {
+  open?: boolean | undefined;
   onAddBrowser: () => void;
   onAddBrowserInProfile: (profileId: string) => void;
   browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>;
@@ -425,11 +444,21 @@
   // is focused; focus moves around too easily (stray clicks) to carry them.
   // Capture phase so app-level key handlers cannot swallow the event first;
   // typing contexts and already-handled events are left alone.
+  const isPanelOpen = props.open !== false;
+  const containerRef = useRef<HTMLDivElement | null>(null);
   const shortcutActionsRef = useRef(availableActions);
+
   useEffect(() => {
     shortcutActionsRef.current = availableActions;
   });
+
   useEffect(() => {
+    if (isPanelOpen) {
+      containerRef.current?.focus();
+    }
+  }, [isPanelOpen]);
+  useEffect(() => {
+    if (!isPanelOpen) return;
     const handler = (event: KeyboardEvent) => {
       const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
       if (!action) return;
@@ -442,9 +471,10 @@
     };
     window.addEventListener("keydown", handler, true);
     return () => window.removeEventListener("keydown", handler, true);
-  }, []);
+  }, [isPanelOpen]);
 
   const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
+    if (!isPanelOpen) return;
     if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
     if (availableActions.length === 0) return;
     if (event.key === "ArrowDown" || event.key === "ArrowRight") {
@@ -471,12 +501,6 @@
     }
   };
 
-  // Stable identity so React only runs this callback ref on mount/unmount;
-  // an inline arrow would re-attach and re-focus on every render.
-  const focusOnMount = useCallback((node: HTMLDivElement | null) => {
-    node?.focus();
-  }, []);
-
   const isHighlighted = (action: SurfaceAction) =>
     highlightIndex !== -1 && availableActions[highlightIndex] === action;
 
@@ -499,11 +523,13 @@
 
   return (
     <div
+      ref={containerRef}
-      ref={focusOnMount}
       tabIndex={0}
       onKeyDown={handleKeyDown}
       aria-label="Open a surface"
+      data-surface-launcher-keys={
-      data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
+        isPanelOpen ? availableActions.map((action) => action.shortcut).join("") : undefined
+      }
       className={cn(
         "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",
         // The panel topbar sits above this container; matching bottom padding
@@ -1405,6 +1431,7 @@
       <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>
         {props.activeSurfaceId === null ? (
           <RightPanelEmptyState
+            open={props.open}
             onAddBrowser={props.onAddBrowser}
             onAddBrowserInProfile={props.onAddBrowserInProfile}
             browserProfiles={browserProfiles}
```

## apps/web/src/components/settings/SettingsPanels.tsx

Status: modified; +37; −0

```diff
@@ -501,6 +501,9 @@
   );
 }
 
+/**
+ * Hook providing dirty-settings tracking and one-click restoration to defaults.
+ */
 export function useSettingsRestore(onRestored?: () => void) {
   const {
     theme,
@@ -621,6 +624,9 @@
       ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
         ? ["Delete confirmation"]
         : []),
+      ...(settings.confirmTerminalClose !== DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose
+        ? ["Terminal close confirmation"]
+        : []),
       ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []),
       ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
       ...getChangedBrowserSettingLabels(settings),
@@ -646,6 +652,7 @@
       settings.confirmThreadArchive,
       settings.confirmThreadDelete,
       settings.confirmThreadUnpin,
+      settings.confirmTerminalClose,
       settings.composerCollapseOnScroll,
       settings.composerRichTextEnabled,
       settings.sendShortcut,
@@ -786,6 +793,7 @@
       confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
       confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
       confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin,
+      confirmTerminalClose: DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose,
       confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
       textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
       fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
@@ -2099,6 +2107,9 @@
   );
 }
 
+/**
+ * Settings panel for general application behavior, confirmations, and shortcuts.
+ */
 export function GeneralSettingsPanel() {
   const modifierLabel = isMacPlatform(navigator.platform) ? "⌘" : "Ctrl";
   const sendShortcutOptions = [
@@ -3034,6 +3045,32 @@
           }
         />
 
+        <SettingsRow
+          {...searchableSetting("terminal-close-confirmation")}
+          description="Ask before closing a terminal and ending its process."
+          resetAction={
+            settings.confirmTerminalClose !== DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose ? (
+              <SettingResetButton
+                label="terminal close confirmation"
+                onClick={() =>
+                  updateSettings({
+                    confirmTerminalClose: DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose,
+                  })
+                }
+              />
+            ) : null
+          }
+          control={
+            <Switch
+              checked={settings.confirmTerminalClose}
+              onCheckedChange={(checked) =>
+                updateSettings({ confirmTerminalClose: Boolean(checked) })
+              }
+              aria-label="Confirm terminal close"
+            />
+          }
+        />
+
         {isElectron ? (
           <SettingsRow
             {...searchableSetting("quit-confirmation")}
```

## apps/web/src/components/settings/settingsSearch.test.ts

Status: modified; +2; −1

```diff
@@ -102,11 +102,12 @@
     expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment");
   });
 
-  it("lists thread confirmations in panel order", () => {
+  it("lists confirmations in panel order", () => {
     expect(searchSettings("confirmation").map((item) => item.id)).toEqual([
       "unpin-confirmation",
       "archive-confirmation",
       "delete-confirmation",
+      "terminal-close-confirmation",
     ]);
   });
 
```

## apps/web/src/components/settings/settingsSearch.ts

Status: modified; +6; −0

```diff
@@ -441,6 +441,12 @@
     searchTerms: ["ask before thread chat history"],
   },
   {
+    id: "terminal-close-confirmation",
+    title: "Terminal close confirmation",
+    to: "/settings/general",
+    searchTerms: ["ask before terminal close dialog popup prompt"],
+  },
+  {
     id: "quit-confirmation",
     title: "Quit shortcut",
     to: "/settings/general",
```

## apps/web/src/confirmDialog.test.ts

Status: modified; +65; −0

```diff
@@ -34,16 +34,20 @@
 
     expect(readConfirmDialogState()).toEqual({
       status: "confirming",
+      id: "confirm-1",
       message: "Delete this thread?",
       variant: "destructive",
+      checkbox: undefined,
     });
 
     respondToConfirmDialog(true);
     await expect(confirmation).resolves.toBe(true);
     expect(readConfirmDialogState()).toEqual({
       status: "closing",
+      id: "confirm-1",
       message: "Delete this thread?",
       variant: "destructive",
+      checkbox: undefined,
     });
 
     completeConfirmDialogClose();
@@ -60,15 +64,19 @@
     await expect(first).resolves.toBe(false);
     expect(readConfirmDialogState()).toEqual({
       status: "closing",
+      id: "confirm-1",
       message: "Delete the project?",
       variant: "default",
+      checkbox: undefined,
     });
 
     completeConfirmDialogClose();
     expect(readConfirmDialogState()).toEqual({
       status: "confirming",
+      id: "confirm-2",
       message: "Delete the worktree too?",
       variant: "default",
+      checkbox: undefined,
     });
 
     respondToConfirmDialog(true);
@@ -78,6 +86,38 @@
     unregister();
   });
 
+  it("assigns distinct IDs to successive confirmations with identical messages", async () => {
+    const unregister = registerConfirmDialogHost();
+    const first = requireConfirmation(requestConfirmDialog("Same message?"));
+    const second = requireConfirmation(requestConfirmDialog("Same message?"));
+
+    const firstState = readConfirmDialogState();
+    expect(firstState).toMatchObject({
+      status: "confirming",
+      id: "confirm-1",
+      message: "Same message?",
+    });
+
+    respondToConfirmDialog(true);
+    await expect(first).resolves.toBe(true);
+    completeConfirmDialogClose();
+
+    const secondState = readConfirmDialogState();
+    expect(secondState).toMatchObject({
+      status: "confirming",
+      id: "confirm-2",
+      message: "Same message?",
+    });
+    expect(secondState.status === "confirming" ? secondState.id : null).not.toBe(
+      firstState.status === "confirming" ? firstState.id : null,
+    );
+
+    respondToConfirmDialog(true);
+    await expect(second).resolves.toBe(true);
+    completeConfirmDialogClose();
+    unregister();
+  });
+
   it("cancels active and queued confirmations if the last host unmounts", async () => {
     const unregister = registerConfirmDialogHost();
     const active = requireConfirmation(requestConfirmDialog("Delete the thread?"));
@@ -101,4 +141,29 @@
     unregister();
     return expect(confirmation).resolves.toBe(true);
   });
+
+  it("passes checkbox options into confirming and queued states", async () => {
+    const unregister = registerConfirmDialogHost();
+    const onCheckedChange = () => undefined;
+    const confirmation = requireConfirmation(
+      requestConfirmDialog("Close terminal?", {
+        variant: "destructive",
+        checkbox: { label: "Don't ask again", checked: false, onCheckedChange },
+      }),
+    );
+
+    expect(readConfirmDialogState()).toEqual({
+      status: "confirming",
+      id: "confirm-1",
+      message: "Close terminal?",
+      variant: "destructive",
+      checkbox: { label: "Don't ask again", checked: false, onCheckedChange },
+    });
+
+    respondToConfirmDialog(true);
+    await expect(confirmation).resolves.toBe(true);
+    completeConfirmDialogClose();
+    expect(readConfirmDialogState()).toEqual({ status: "idle" });
+    unregister();
+  });
 });
```

## apps/web/src/confirmDialog.ts

Status: modified; +45; −4

```diff
@@ -1,31 +1,43 @@
+import type {
+  ConfirmDialogCheckboxOptions,
+  ConfirmDialogOptions,
+  ConfirmDialogVariant,
-import type { ConfirmDialogOptions, ConfirmDialogVariant } from "@t3tools/contracts";
+} from "@t3tools/contracts";
 
 export type ConfirmDialogState =
   | { readonly status: "idle" }
   | {
       readonly status: "confirming";
+      readonly id: string;
       readonly message: string;
       readonly variant: ConfirmDialogVariant;
+      readonly checkbox?: ConfirmDialogCheckboxOptions | undefined;
     }
   | {
       readonly status: "closing";
+      readonly id: string;
       readonly message: string;
       readonly variant: ConfirmDialogVariant;
+      readonly checkbox?: ConfirmDialogCheckboxOptions | undefined;
     };
 
 type PendingConfirmation = {
+  readonly id: string;
   readonly message: string;
   readonly variant: ConfirmDialogVariant;
+  readonly checkbox?: ConfirmDialogCheckboxOptions | undefined;
   readonly resolve: (confirmed: boolean) => void;
 };
 
 const idleState: ConfirmDialogState = { status: "idle" };
 let state: ConfirmDialogState = idleState;
+let confirmationSequence = 0;
 let activeConfirmation: PendingConfirmation | null = null;
 let queuedConfirmations: PendingConfirmation[] = [];
 let registeredHostCount = 0;
 const listeners = new Set<() => void>();
 
+/** Dispatches a new dialog state to all registered listeners. */
 function publish(next: ConfirmDialogState): void {
   state = next;
   for (const listener of listeners) {
@@ -33,6 +45,7 @@
   }
 }
 
+/** Resolves all pending and queued confirmations with the given result. */
 function resolvePendingConfirmations(confirmed: boolean): void {
   activeConfirmation?.resolve(confirmed);
   for (const confirmation of queuedConfirmations) {
@@ -42,10 +55,12 @@
   queuedConfirmations = [];
 }
 
+/** Returns the current confirmation dialog state. */
 export function readConfirmDialogState(): ConfirmDialogState {
   return state;
 }
 
+/** Subscribes a listener to confirmation dialog state changes. */
 export function subscribeConfirmDialog(listener: () => void): () => void {
   listeners.add(listener);
   return () => {
@@ -84,9 +99,13 @@
   if (registeredHostCount === 0) return undefined;
 
   const confirmation = new Promise<boolean>((resolve) => {
+    confirmationSequence += 1;
+    const id = `confirm-${confirmationSequence}`;
     const pending = {
+      id,
       message,
       variant: options?.variant ?? "default",
+      checkbox: options?.checkbox,
       resolve,
     } satisfies PendingConfirmation;
     if (activeConfirmation || state.status === "closing") {
@@ -95,21 +114,35 @@
     }
 
     activeConfirmation = pending;
+    publish({
+      status: "confirming",
+      id,
+      message,
+      variant: pending.variant,
+      checkbox: pending.checkbox,
-    publish({ status: "confirming", message, variant: pending.variant });
+    });
   });
 
   return confirmation;
 }
 
+/** Resolves the active confirmation and transitions the dialog to closing. */
 export function respondToConfirmDialog(confirmed: boolean): void {
   if (state.status !== "confirming" || !activeConfirmation) return;
 
   const confirmation = activeConfirmation;
   activeConfirmation = null;
   confirmation.resolve(confirmed);
-  publish({ status: "closing", message: state.message, variant: state.variant });
+  publish({
+    status: "closing",
+    id: state.id,
+    message: state.message,
+    variant: state.variant,
+    checkbox: state.checkbox,
+  });
 }
 
+/** Completes the closing animation and activates any queued confirmation. */
 export function completeConfirmDialogClose(): void {
   if (state.status !== "closing") return;
 
@@ -120,11 +153,19 @@
   }
 
   activeConfirmation = next;
-  publish({ status: "confirming", message: next.message, variant: next.variant });
+  publish({
+    status: "confirming",
+    id: next.id,
+    message: next.message,
+    variant: next.variant,
+    checkbox: next.checkbox,
+  });
 }
 
+/** Resets confirmation dialog state for tests. */
 export function resetConfirmDialogForTests(): void {
   resolvePendingConfirmations(false);
+  confirmationSequence = 0;
   registeredHostCount = 0;
   publish(idleState);
   listeners.clear();
```

## apps/web/src/lib/terminalCloseConfirm.test.ts

Status: modified; +76; −12

```diff
@@ -1,21 +1,34 @@
 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
 
-const { confirmMock, readLocalApiMock } = vi.hoisted(() => {
-  const confirmMock = vi.fn<(message: string, options?: unknown) => Promise<boolean>>();
-  const readLocalApiMock = vi.fn<
-    () =>
-      | {
-          dialogs: { confirm: (message: string, options?: unknown) => Promise<boolean> };
-        }
-      | undefined
-  >();
-  return { confirmMock, readLocalApiMock };
-});
+const { confirmMock, readLocalApiMock, clientSettingsMock, persistClientSettingsUpdateMock } =
+  vi.hoisted(() => {
+    const confirmMock = vi.fn<(message: string, options?: unknown) => Promise<boolean>>();
+    const readLocalApiMock = vi.fn<
+      () =>
+        | {
+            dialogs: { confirm: (message: string, options?: unknown) => Promise<boolean> };
+          }
+        | undefined
+    >();
+    const clientSettingsMock = { confirmTerminalClose: true };
+    const persistClientSettingsUpdateMock = vi.fn<(patch: unknown) => Promise<void>>();
+    return {
+      confirmMock,
+      readLocalApiMock,
+      clientSettingsMock,
+      persistClientSettingsUpdateMock,
+    };
+  });
 
 vi.mock("~/localApi", () => ({
   readLocalApi: () => readLocalApiMock(),
 }));
 
+vi.mock("~/hooks/useSettings", () => ({
+  getClientSettings: () => clientSettingsMock,
+  persistClientSettingsUpdate: (update: (s: any) => any) => persistClientSettingsUpdateMock(update),
+}));
+
 import { confirmTerminalClose, isTerminalCloseConfirmPending } from "./terminalCloseConfirm";
 
 describe("terminal close confirmation", () => {
@@ -23,6 +36,8 @@
     confirmMock.mockReset();
     readLocalApiMock.mockReset();
     readLocalApiMock.mockReturnValue({ dialogs: { confirm: confirmMock } });
+    clientSettingsMock.confirmTerminalClose = true;
+    persistClientSettingsUpdateMock.mockReset();
   });
 
   it("tracks pending state until the confirmation settles", async () => {
@@ -65,7 +80,12 @@
         "Close 2 terminals?",
         'This stops their running processes and clears their histories: "Terminal 1", "Development server".',
       ].join("\n"),
+      {
+        variant: "destructive",
+        checkbox: expect.objectContaining({
+          label: "Don't ask again",
+        }),
-      { variant: "destructive" },
+      },
     );
   });
 
@@ -75,4 +95,48 @@
     await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true);
     expect(confirmMock).not.toHaveBeenCalled();
   });
+
+  it("closes without prompting when confirmTerminalClose is disabled in settings", async () => {
+    clientSettingsMock.confirmTerminalClose = false;
+
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true);
+    expect(confirmMock).not.toHaveBeenCalled();
+    expect(isTerminalCloseConfirmPending()).toBe(false);
+  });
+
+  it("persists confirmTerminalClose: false when confirmed with checkbox checked", async () => {
+    confirmMock.mockImplementation(async (_msg, options: any) => {
+      options.checkbox?.onCheckedChange?.(true);
+      return true;
+    });
+
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true);
+    expect(persistClientSettingsUpdateMock).toHaveBeenCalled();
+    const updateFn = persistClientSettingsUpdateMock.mock.calls[0]?.[0];
+    expect(updateFn({ confirmTerminalClose: true, other: "keep" })).toEqual({
+      confirmTerminalClose: false,
+      other: "keep",
+    });
+  });
+
+  it("does not persist settings when cancelled even if checkbox was checked", async () => {
+    confirmMock.mockImplementation(async (_msg, options: any) => {
+      options.checkbox?.onCheckedChange?.(true);
+      return false;
+    });
+
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(false);
+    expect(persistClientSettingsUpdateMock).not.toHaveBeenCalled();
+  });
+
+  it("still resolves true when confirmed with checkbox checked even if settings persistence fails", async () => {
+    confirmMock.mockImplementation(async (_msg, options: any) => {
+      options.checkbox?.onCheckedChange?.(true);
+      return true;
+    });
+    persistClientSettingsUpdateMock.mockRejectedValue(new Error("disk full"));
+
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true);
+    expect(isTerminalCloseConfirmPending()).toBe(false);
+  });
 });
```

## apps/web/src/lib/terminalCloseConfirm.ts

Status: modified; +26; −2

```diff
@@ -1,3 +1,4 @@
+import { getClientSettings, persistClientSettingsUpdate } from "~/hooks/useSettings";
 import { readLocalApi } from "~/localApi";
 
 let pendingConfirmations = 0;
@@ -18,9 +19,13 @@
 ): Promise<boolean> {
   const localApi = readLocalApi();
   if (!localApi) return true;
+  if (!getClientSettings().confirmTerminalClose) {
+    return true;
+  }
   pendingConfirmations += 1;
   try {
+    let dontAskAgain = false;
+    const confirmed = await localApi.dialogs.confirm(
-    return await localApi.dialogs.confirm(
       labels.length === 1
         ? [
             `Close terminal "${labels[0]}"?`,
@@ -32,8 +37,27 @@
               .map((label) => `"${label}"`)
               .join(", ")}.`,
           ].join("\n"),
+      {
+        variant: "destructive",
+        checkbox: {
+          label: "Don't ask again",
+          onCheckedChange: (checked) => {
+            dontAskAgain = checked;
-      { variant: "destructive" },
+          },
+        },
+      },
     );
+    if (confirmed && dontAskAgain) {
+      try {
+        await persistClientSettingsUpdate((settings) => ({
+          ...settings,
+          confirmTerminalClose: false,
+        }));
+      } catch {
+        // Preference persistence failure should not block terminal close when affirmative
+      }
+    }
+    return confirmed;
   } catch {
     return false;
   } finally {
```

## packages/contracts/src/ipc.ts

Status: modified; +8; −1

```diff
@@ -1346,8 +1346,15 @@
 
 export type ConfirmDialogVariant = "default" | "destructive";
 
+export interface ConfirmDialogCheckboxOptions {
+  readonly label: string;
+  readonly checked?: boolean;
+  readonly onCheckedChange?: (checked: boolean) => void;
+}
+
 export interface ConfirmDialogOptions {
-  readonly variant?: ConfirmDialogVariant;
+  readonly variant?: ConfirmDialogVariant | undefined;
+  readonly checkbox?: ConfirmDialogCheckboxOptions | undefined;
 }
 
 /**
```

## packages/contracts/src/settings.test.ts

Status: modified; +8; −0

```diff
@@ -589,6 +589,14 @@
     expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true);
     expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow();
   });
+
+  it("keeps terminal close confirmation opt-out and patchable", () => {
+    expect(decodeClientSettings({}).confirmTerminalClose).toBe(true);
+    expect(decodeClientSettingsPatch({ confirmTerminalClose: false }).confirmTerminalClose).toBe(
+      false,
+    );
+    expect(() => decodeClientSettingsPatch({ confirmTerminalClose: "yes" })).toThrow();
+  });
 });
 
 describe("ClientSettings context window meter", () => {
```

## packages/contracts/src/settings.ts

Status: modified; +2; −0

```diff
@@ -363,6 +363,7 @@
   confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
   confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+  confirmTerminalClose: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
     Schema.withDecodingDefault(Effect.succeed([])),
   ),
@@ -1581,6 +1582,7 @@
   confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
   confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
   confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
+  confirmTerminalClose: Schema.optionalKey(Schema.Boolean),
   diffFilesCollapsed: Schema.optionalKey(Schema.Boolean),
   diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
   diffLayout: Schema.optionalKey(DiffLayout),
```
