Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
id: TASK-276
title: Confirm before closing a tab/pane session
status: In Progress
assignee:
- yoziv
created_date: '2026-07-13 16:18'
updated_date: '2026-07-13 16:20'
labels: []
dependencies: []
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
User accidentally clicks the tab close X (or middle-clicks) when reaching for a tab to return to it, losing a live terminal/AI session. Add an opt-in confirmation dialog before closing a tab/pane, reusing the existing tmax-styled confirmDialog (AppDialog.tsx, TASK-115). Bulk closes (Close All/Others/group) get a single aggregate confirm, not one per tab.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A new opt-in setting (default off) gates confirm-before-close; when off, closing is unchanged
- [ ] #2 With the setting on, clicking the tab X or middle-clicking a tab shows a confirm dialog naming the session; Cancel keeps the session, Close removes it
- [ ] #3 Bulk closes (Close All / Close Others / close group / multi-select close) show a single aggregate confirm, never one dialog per tab
- [ ] #4 Setting persists across restarts and has a toggle in Settings > Tabs
<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
1. Add opt-in setting confirmOnCloseSession (default false) in terminal-store: state field, default value, config load, and toggleConfirmOnCloseSession action - mirroring hideTabCloseButtons.
<!-- SECTION:PLAN:END -->
7 changes: 7 additions & 0 deletions src/renderer/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,13 @@ const AppearanceSettings: React.FC = () => {
<span className="toggle-track" />
</label>
</SettingRow>
<SettingRow label="Confirm before closing" description="Ask for confirmation before closing a tab or pane, so an accidental ✕-click or middle-click doesn't end a live session">
<label className="toggle-switch">
<input type="checkbox" checked={(config as any).confirmOnCloseSession === true}
onChange={() => useTerminalStore.getState().toggleConfirmOnCloseSession()} />
<span className="toggle-track" />
</label>
</SettingRow>
<SettingRow label="Default tab color" description="Background tint for all terminals without a custom color">
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" className="theme-color-picker"
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ const TabBar: React.FC<{ vertical?: boolean; side?: 'left' | 'right' }> = ({ ver
.filter(([, t]) => t.groupId === groupMenu.groupId)
.map(([id]) => id);
store.deleteTabGroup(groupMenu.groupId);
(async () => { for (const id of ids) await store.closeTerminal(id); })();
store.closeTerminals(ids);
setGroupMenu(null);
}}>
Close All
Expand Down
6 changes: 3 additions & 3 deletions src/renderer/components/TabContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,21 +447,21 @@ const TabContextMenu: React.FC<TabContextMenuProps> = ({ position, selectedAtOpe
: [position.terminalId];
onClose();
useTerminalStore.getState().clearSelection();
(async () => { for (const id of ids) await useTerminalStore.getState().closeTerminal(id); })();
useTerminalStore.getState().closeTerminals(ids);
}}>
Close{targetIds.length > 1 ? ` (${targetIds.length})` : ''} <span className="shortcut">{formatKeyForPlatform('Ctrl+Shift+W')}</span>
</button>
<button className="context-menu-item danger" onClick={() => {
onClose();
const ids = Array.from(store().terminals.keys()).filter((id) => id !== position.terminalId);
(async () => { for (const id of ids) await store().closeTerminal(id); })();
store().closeTerminals(ids);
}}>
Close Others
</button>
<button className="context-menu-item danger" onClick={() => {
onClose();
const ids = Array.from(store().terminals.keys());
(async () => { for (const id of ids) await store().closeTerminal(id); })();
store().closeTerminals(ids);
}}>
Close All
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/hooks/useKeybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ function dispatchAction(action: string): void {
: (focusedId ? [focusedId] : []);
if (ids.length === 0) break;
if (sel.length > 0) store.clearSelection();
(async () => { for (const id of ids) await store.closeTerminal(id); })();
store.closeTerminals(ids);
break;
}
case 'restoreClosedTerminal':
Expand Down
54 changes: 52 additions & 2 deletions src/renderer/state/terminal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ interface TerminalStore {
tabBarPosition: 'top' | 'bottom' | 'left' | 'right';
hideTabTitles: boolean;
hideTabCloseButtons: boolean;
// When true, closing a tab/pane asks for confirmation first so an
// accidental X-click or middle-click doesn't kill a live session. Bulk
// closes collapse to a single aggregate confirm. Opt-in; default off.
confirmOnCloseSession: boolean;
renamingTerminalId: TerminalId | null;
viewMode: 'split' | 'focus' | 'grid';
broadcastMode: boolean; // when true, typing in any pane is sent to all tiled panes
Expand Down Expand Up @@ -988,7 +992,10 @@ interface TerminalStore {
// Actions
loadConfig: () => Promise<void>;
createTerminal: (shellProfileId?: string, cwdOverride?: string) => Promise<void>;
closeTerminal: (id: TerminalId) => Promise<void>;
closeTerminal: (id: TerminalId, opts?: { skipConfirm?: boolean }) => Promise<void>;
// Close many tabs at once with a single aggregate confirm (instead of
// one dialog per tab). Used by Close All / Close Others / close group.
closeTerminals: (ids: TerminalId[]) => Promise<void>;
replaceTerminal: (id: TerminalId, shellProfileId?: string) => Promise<void>;
/**
* Browser-style undo close (TASK-112). Pops the most recent entry off
Expand Down Expand Up @@ -1034,6 +1041,7 @@ interface TerminalStore {
toggleTabBarPosition: () => void;
toggleHideTabTitles: () => void;
toggleHideTabCloseButtons: () => void;
toggleConfirmOnCloseSession: () => void;
setTerminalOpacity: (opacity: number) => void;
startRenaming: (id: TerminalId | null) => void;
toggleViewMode: () => void;
Expand Down Expand Up @@ -1376,6 +1384,7 @@ export const useTerminalStore = create<TerminalStore>((set, get) => ({
tabBarPosition: 'top' as 'top' | 'bottom' | 'left' | 'right',
hideTabTitles: false,
hideTabCloseButtons: false,
confirmOnCloseSession: false,
renamingTerminalId: null,
viewMode: 'grid' as 'split' | 'focus' | 'grid',
broadcastMode: false,
Expand All @@ -1399,6 +1408,7 @@ export const useTerminalStore = create<TerminalStore>((set, get) => ({
if (config?.tabBarPosition) updates.tabBarPosition = config.tabBarPosition;
if (typeof (config as any)?.hideTabTitles === 'boolean') updates.hideTabTitles = (config as any).hideTabTitles;
if (typeof (config as any)?.hideTabCloseButtons === 'boolean') updates.hideTabCloseButtons = (config as any).hideTabCloseButtons;
if (typeof (config as any)?.confirmOnCloseSession === 'boolean') updates.confirmOnCloseSession = (config as any).confirmOnCloseSession;
if ((config as any)?.terminalOpacity != null) {
updates.terminalOpacity = (config as any).terminalOpacity;
document.documentElement.style.setProperty('--terminal-opacity', String((config as any).terminalOpacity));
Expand Down Expand Up @@ -1613,12 +1623,26 @@ export const useTerminalStore = create<TerminalStore>((set, get) => ({
get().saveSession();
},

closeTerminal: async (id: TerminalId) => {
closeTerminal: async (id: TerminalId, opts?: { skipConfirm?: boolean }) => {
const t0 = performance.now();
const { terminals, layout, focusedTerminalId, closedTerminals, copilotSessions, claudeCodeSessions } = get();
const instance = terminals.get(id);
if (!instance) return;

// Guard against an accidental X-click / middle-click killing a live
// session. skipConfirm lets bulk closers show one aggregate confirm
// instead of one dialog per pane (see closeTerminals).
if (get().confirmOnCloseSession && !opts?.skipConfirm) {
const label = instance.title?.trim() || 'this session';
const ok = await confirmDialog({
title: 'Close session?',
message: `Close "${label}"? This ends the terminal session.`,
confirmText: 'Close',
danger: true,
});
if (!ok) return;
}

// Snapshot pane identity for browser-style undo close (TASK-112).
// Capture BEFORE the PTY is killed so the metadata is intact even if
// killPty surfaces an error. Cap at 10 - older entries fall off the
Expand Down Expand Up @@ -1722,6 +1746,26 @@ export const useTerminalStore = create<TerminalStore>((set, get) => ({
});
},

closeTerminals: async (ids: TerminalId[]) => {
if (ids.length === 0) return;
// A single tab still names itself; let closeTerminal own that confirm.
if (ids.length === 1) {
await get().closeTerminal(ids[0]);
return;
}
// Many tabs: one aggregate confirm, then close each without re-asking.
if (get().confirmOnCloseSession) {
const ok = await confirmDialog({
title: 'Close sessions?',
message: `Close ${ids.length} sessions? This ends all of them.`,
confirmText: `Close ${ids.length}`,
danger: true,
});
if (!ok) return;
}
for (const id of ids) await get().closeTerminal(id, { skipConfirm: true });
},

restoreClosedTerminal: async () => {
const { closedTerminals } = get();
if (closedTerminals.length === 0) return;
Expand Down Expand Up @@ -2646,6 +2690,12 @@ export const useTerminalStore = create<TerminalStore>((set, get) => ({
get().updateConfig({ hideTabCloseButtons: val } as any);
},

toggleConfirmOnCloseSession: () => {
const val = !get().confirmOnCloseSession;
set({ confirmOnCloseSession: val });
get().updateConfig({ confirmOnCloseSession: val } as any);
},

setTerminalOpacity: (opacity: number) => {
const clamped = Math.max(0.3, Math.min(1, opacity));
set({ terminalOpacity: clamped });
Expand Down
Loading