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

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

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

Visibility: public

[feat\(web\): make terminal close confirmation optional](https://git.cafe/pingdotgg/t3code/pull/16615.md)

Version: 10

Head commit: 1e0c04d4d177857ea5e8c11c2dc416b6be88372d

Base commit: 67c0fc0cdaf963b103518862dd7b68a9c758ecf2

Comparison base: 67c0fc0cdaf963b103518862dd7b68a9c758ecf2

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

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

Status: modified; +29; −0

```diff
@@ -649,6 +649,8 @@
     ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
       ? ["Archive confirmation"]
       : []),
+    ...(settings.confirmTerminalClose !== DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose
+      ? ["Terminal close confirmation"] : []),
     ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
       ? ["Delete confirmation"]
       : []),
@@ -759,6 +761,7 @@
       newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
       addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
       confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
+      confirmTerminalClose: DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose,
       confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
       confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin,
       confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
@@ -3135,6 +3138,32 @@
           }
         />
 
+        <SettingsRow
+          {...searchableSetting("terminal-close-confirmation")}
+          description="Ask before closing terminals and stopping their running processes."
+          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 closing"
+            />
+          }
+        />
+
         {isElectron ? (
           <SettingsRow
             {...searchableSetting("quit-confirmation")}
```

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

Status: modified; +2; −1

```diff
@@ -115,11 +115,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
@@ -511,6 +511,12 @@
     searchTerms: ["ask before thread chat history"],
   },
   {
+    id: "terminal-close-confirmation",
+    title: "Terminal close confirmation",
+    to: "/settings/general",
+    searchTerms: ["ask before delete stop process shell console history"],
+  },
+  {
     id: "quit-confirmation",
     title: "Quit shortcut",
     to: "/settings/general",
```

## apps/web/src/hooks/useRightPanelSurfaceClose.ts

Status: modified; +15; −1

```diff
@@ -108,7 +108,21 @@
     terminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
   const closeSurfaces = (toClose: readonly RightPanelSurface[]) => {
     if (!threadRef) return;
-    closeAfterAgentBrowserConfirmation(toClose, () => finishClose(toClose));
+    closeAfterAgentBrowserConfirmation(toClose, () => {
+      const labels = readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)
+        ? toClose.flatMap((surface) =>
+            surface.kind === "terminal" ? surface.terminalIds.map(labelFor) : [],
+          )
+        : [];
+      const first = labels[0];
+      if (first === undefined) {
+        finishClose(toClose);
+        return;
+      }
+      void confirmTerminalClose([first, ...labels.slice(1)]).then((confirmed) => {
+        if (confirmed) finishClose(toClose);
+      });
+    });
   };
 
   const closeSurface = useStableCallback((surface: RightPanelSurface) => {
```

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

Status: modified; +63; −2

```diff
@@ -1,6 +1,6 @@
 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
 
-const { confirmMock, readLocalApiMock } = vi.hoisted(() => {
+const { confirmMock, readLocalApiMock, settingsMock, hydrateMock } = vi.hoisted(() => {
   const confirmMock = vi.fn<(message: string, options?: unknown) => Promise<boolean>>();
   const readLocalApiMock = vi.fn<
     () =>
@@ -9,9 +9,19 @@
         }
       | undefined
   >();
+  return {
+    confirmMock,
+    readLocalApiMock,
-  return { confirmMock, readLocalApiMock };
+    settingsMock: { confirmTerminalClose: true },
+    hydrateMock: vi.fn<() => Promise<void>>(),
+  };
 });
 
+vi.mock("~/hooks/useSettings", () => ({
+  getClientSettings: () => settingsMock,
+  ensureClientSettingsHydrated: () => hydrateMock(),
+}));
+
 vi.mock("~/localApi", () => ({
   readLocalApi: () => readLocalApiMock(),
 }));
@@ -20,11 +30,60 @@
 
 describe("terminal close confirmation", () => {
   beforeEach(() => {
+    settingsMock.confirmTerminalClose = true;
+    hydrateMock.mockReset();
+    hydrateMock.mockResolvedValue(undefined);
     confirmMock.mockReset();
     readLocalApiMock.mockReset();
     readLocalApiMock.mockReturnValue({ dialogs: { confirm: confirmMock } });
   });
 
+  it("closes single and multiple terminals immediately when confirmation is disabled", async () => {
+    settingsMock.confirmTerminalClose = false;
+
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true);
+    await expect(confirmTerminalClose(["Terminal 1", "Terminal 2"])).resolves.toBe(true);
+    expect(confirmMock).not.toHaveBeenCalled();
+    expect(isTerminalCloseConfirmPending()).toBe(false);
+
+    settingsMock.confirmTerminalClose = true;
+    confirmMock.mockResolvedValue(false);
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(false);
+    expect(confirmMock).toHaveBeenCalledOnce();
+  });
+
+  it("waits for the saved opt-out before closing immediately after reload", async () => {
+    let finishHydration: () => void = () => undefined;
+    hydrateMock.mockImplementation(
+      () =>
+        new Promise<void>((resolve) => {
+          finishHydration = () => {
+            settingsMock.confirmTerminalClose = false;
+            resolve();
+          };
+        }),
+    );
+
+    const close = confirmTerminalClose(["Terminal 1"]);
+    expect(isTerminalCloseConfirmPending()).toBe(true);
+    expect(confirmMock).not.toHaveBeenCalled();
+
+    finishHydration();
+    await expect(close).resolves.toBe(true);
+    expect(confirmMock).not.toHaveBeenCalled();
+    expect(isTerminalCloseConfirmPending()).toBe(false);
+  });
+
+  it("asks for confirmation when saved settings cannot be loaded", async () => {
+    settingsMock.confirmTerminalClose = false;
+    hydrateMock.mockRejectedValue(new Error("storage unavailable"));
+    confirmMock.mockResolvedValue(false);
+
+    await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(false);
+    expect(confirmMock).toHaveBeenCalledOnce();
+    expect(isTerminalCloseConfirmPending()).toBe(false);
+  });
+
   it("tracks pending state until the confirmation settles", async () => {
     let settle: (value: boolean) => void = () => undefined;
     confirmMock.mockImplementation(() => new Promise<boolean>((resolve) => (settle = resolve)));
@@ -34,6 +93,7 @@
     const confirmation = confirmTerminalClose(["Terminal 1"]);
     expect(isTerminalCloseConfirmPending()).toBe(true);
 
+    await hydrateMock.mock.results[0]!.value;
     settle(true);
     await expect(confirmation).resolves.toBe(true);
     expect(isTerminalCloseConfirmPending()).toBe(false);
@@ -51,6 +111,7 @@
     const confirmation = confirmTerminalClose(["Terminal 1"]);
     expect(isTerminalCloseConfirmPending()).toBe(true);
 
+    await hydrateMock.mock.results[0]!.value;
     reject(new Error("dialog failed"));
     await expect(confirmation).resolves.toBe(false);
     expect(isTerminalCloseConfirmPending()).toBe(false);
```

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

Status: modified; +10; −0

```diff
@@ -1,7 +1,9 @@
+import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
 import { readLocalApi } from "~/localApi";
 
 let pendingConfirmations = 0;
 
+/** Whether a terminal-close action is waiting on settings or user confirmation. */
 export function isTerminalCloseConfirmPending(): boolean {
   return pendingConfirmations > 0;
 }
@@ -13,6 +15,14 @@
   if (!localApi) return true;
   pendingConfirmations += 1;
   try {
+    let shouldConfirm = true;
+    try {
+      await ensureClientSettingsHydrated();
+      shouldConfirm = getClientSettings().confirmTerminalClose;
+    } catch {
+      // If saved settings cannot be read, keep the destructive confirmation.
+    }
+    if (!shouldConfirm) return true;
     return await localApi.dialogs.confirm(
       labels.length === 1
         ? [
```

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

Status: modified; +12; −0

```diff
@@ -149,6 +149,18 @@
   });
 });
 
+describe("ClientSettings terminal close confirmation", () => {
+  it("keeps confirmation enabled for settings without a saved preference", () => {
+    expect(decodeClientSettings({}).confirmTerminalClose).toBe(true);
+  });
+
+  it("preserves an explicit opt-out through patches and persistence", () => {
+    const preference = { confirmTerminalClose: false };
+    expect(decodeClientSettingsPatch(preference)).toEqual(preference);
+    expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference);
+  });
+});
+
 describe("ClientSettings rich text composer", () => {
   it("keeps math opt-in and preserves its preference through patches and persistence", () => {
     expect(decodeClientSettings({}).mathRenderingEnabled).toBe(false);
```

## packages/contracts/src/settings.ts

Status: modified; +2; −0

```diff
@@ -377,6 +377,7 @@
     Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)),
   ),
   confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+  confirmTerminalClose: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
   dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
@@ -1606,6 +1607,7 @@
   browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),
   confirmQuit: Schema.optionalKey(QuitConfirmationMode),
   confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
+  confirmTerminalClose: Schema.optionalKey(Schema.Boolean),
   confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
   confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
   diffFilesCollapsed: Schema.optionalKey(Schema.Boolean),
```
