diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index a0bc62f10..a6d5b7f52 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -77,6 +77,7 @@ public struct SettingsFeature { public var agentPresenceBadgesEnabled: Bool public var autoUpdateAgentIntegrationsEnabled: Bool public var confirmQuitMode: ConfirmQuitMode + public var confirmCloseSurface: Bool public var terminateSessionsOnQuit: Bool public var remoteSessionPersistenceEnabled: Bool public var appVisibility: AppVisibility @@ -131,6 +132,7 @@ public struct SettingsFeature { agentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled autoUpdateAgentIntegrationsEnabled = settings.autoUpdateAgentIntegrationsEnabled confirmQuitMode = settings.confirmQuitMode + confirmCloseSurface = settings.confirmCloseSurface terminateSessionsOnQuit = settings.terminateSessionsOnQuit remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled appVisibility = settings.appVisibility @@ -174,6 +176,7 @@ public struct SettingsFeature { agentPresenceBadgesEnabled: agentPresenceBadgesEnabled, autoUpdateAgentIntegrationsEnabled: autoUpdateAgentIntegrationsEnabled, confirmQuitMode: confirmQuitMode, + confirmCloseSurface: confirmCloseSurface, terminateSessionsOnQuit: terminateSessionsOnQuit, remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled, appVisibility: appVisibility @@ -311,6 +314,7 @@ public struct SettingsFeature { state.agentPresenceBadgesEnabled = normalizedSettings.agentPresenceBadgesEnabled state.autoUpdateAgentIntegrationsEnabled = normalizedSettings.autoUpdateAgentIntegrationsEnabled state.confirmQuitMode = normalizedSettings.confirmQuitMode + state.confirmCloseSurface = normalizedSettings.confirmCloseSurface state.terminateSessionsOnQuit = normalizedSettings.terminateSessionsOnQuit state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled state.appVisibility = normalizedSettings.appVisibility diff --git a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift index e2c0ccd08..19d844007 100644 --- a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift @@ -120,6 +120,10 @@ public struct AppearanceSettingsView: View { Text("Hide Tab Bar for Single Tab") Text("Automatically hides the tab bar when only one tab is open.") } + Toggle(isOn: $store.confirmCloseSurface) { + Text("Confirm before Closing Terminals") + Text("Ask before closing a terminal that has a running process.") + } Picker(selection: $store.automatedActionPolicy.sending(\.setAutomatedActionPolicy)) { ForEach(AutomatedActionPolicy.allCases, id: \.self) { policy in Text(policy.displayName).tag(policy) diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index e1835a41b..c2786b406 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -95,6 +95,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { /// entries from earlier wire-protocol revisions). public var autoUpdateAgentIntegrationsEnabled: Bool public var confirmQuitMode: ConfirmQuitMode + /// When true, user-initiated closes ask for confirmation when a terminal + /// surface has foreground work that Ghostty considers unsafe to interrupt. + public var confirmCloseSurface: Bool /// When true, quitting Supacode also closes every terminal tab and tears /// down zmx sessions, local and host-side, so nothing keeps running in the /// background. Default off because persistence is the headline feature. @@ -138,6 +141,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { agentPresenceBadgesEnabled: true, autoUpdateAgentIntegrationsEnabled: true, confirmQuitMode: .auto, + confirmCloseSurface: true, terminateSessionsOnQuit: false, remoteSessionPersistenceEnabled: true, appVisibility: .dock @@ -176,6 +180,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { agentPresenceBadgesEnabled: Bool = true, autoUpdateAgentIntegrationsEnabled: Bool = true, confirmQuitMode: ConfirmQuitMode = .auto, + confirmCloseSurface: Bool = true, terminateSessionsOnQuit: Bool = false, remoteSessionPersistenceEnabled: Bool = true, appVisibility: AppVisibility = .dock @@ -212,6 +217,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.agentPresenceBadgesEnabled = agentPresenceBadgesEnabled self.autoUpdateAgentIntegrationsEnabled = autoUpdateAgentIntegrationsEnabled self.confirmQuitMode = confirmQuitMode + self.confirmCloseSurface = confirmCloseSurface self.terminateSessionsOnQuit = terminateSessionsOnQuit self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled self.appVisibility = appVisibility @@ -376,6 +382,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { } else { confirmQuitMode = Self.default.confirmQuitMode } + confirmCloseSurface = + try container.decodeIfPresent(Bool.self, forKey: .confirmCloseSurface) + ?? Self.default.confirmCloseSurface terminateSessionsOnQuit = try container.decodeIfPresent(Bool.self, forKey: .terminateSessionsOnQuit) ?? Self.default.terminateSessionsOnQuit diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 3488b9b19..9b1a95793 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -13,6 +13,7 @@ private let terminalLogger = SupaLogger("Terminal") @Observable final class WorktreeTerminalManager { private let runtime: GhosttyRuntime + @ObservationIgnored private let surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? private(set) var socketServer: AgentHookSocketServer? private var states: [Worktree.ID: WorktreeTerminalState] = [:] @ObservationIgnored @@ -148,9 +149,11 @@ final class WorktreeTerminalManager { socketServer: AgentHookSocketServer? = nil, clock: C = ContinuousClock(), eventBufferCap: Int = WorktreeTerminalManager.defaultEventBufferCap, + surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? = nil ) { self.eventBufferCap = eventBufferCap self.runtime = runtime + self.surfaceBindingActionPerformer = surfaceBindingActionPerformer self.focusedSurfaceBackground = runtime.backgroundColor() self.hookEventSleep = { duration in try await clock.sleep(for: duration) } self.layoutDebounceSleep = { duration in try await clock.sleep(for: duration) } @@ -524,7 +527,7 @@ final class WorktreeTerminalManager { existing.enableSetupScriptIfNeeded() } // Reload snapshot if the state has no tabs (e.g., setting was just enabled). - // If `hasAttemptedInitialTab` is sticky-true (closeAllTabs path), the snapshot + // If `hasAttemptedInitialTab` is sticky-true (every tab was closed), the snapshot // stays staged but ensureInitialTab won't consume it; that's intentional. if existing.tabManager.tabs.isEmpty, existing.pendingLayoutSnapshot == nil, @@ -538,7 +541,8 @@ final class WorktreeTerminalManager { let state = WorktreeTerminalState( runtime: runtime, worktree: worktree, - runSetupScript: runSetupScript + runSetupScript: runSetupScript, + surfaceBindingActionPerformer: surfaceBindingActionPerformer ) state.socketPath = socketServer?.socketPath // Load saved layout snapshot for restoration (skip when a setup script is pending). diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index bf7c496b3..92262939f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -49,6 +49,15 @@ final class WorktreeTerminalState { let isVisible: Bool let isFocused: Bool } + enum PendingCloseConfirmation: Equatable { + case surface(UUID) + case tabs([TerminalTabID]) + + // Copy is identical for surface and tab closes, so it lives on the type. + static let title = "Close Terminal?" + static let actionTitle = "Close Terminal" + static let message = "One or more terminal processes are still running. Closing will terminate them." + } private struct SurfaceLaunchMetadata { let usesZmx: Bool @@ -58,6 +67,8 @@ final class WorktreeTerminalState { let tabManager: TerminalTabManager private let runtime: GhosttyRuntime @ObservationIgnored private let splitPreserveZoomOnNavigation: () -> Bool + @ObservationIgnored private let surfaceNeedsCloseConfirmation: (GhosttySurfaceView) -> Bool + @ObservationIgnored private let surfaceBindingActionPerformer: (GhosttySurfaceView, String) -> Void private let worktree: Worktree @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings @@ -70,6 +81,8 @@ final class WorktreeTerminalState { @ObservationIgnored private var surfaceLaunchMetadata: [UUID: SurfaceLaunchMetadata] = [:] // Surfaces the user explicitly closed, so an unexpected zmx exit isn't mistaken for one and reattached. @ObservationIgnored private var pendingExplicitSurfaceCloseIDs: Set = [] + // Explicit closes that skip the confirmation alert (programmatic destroys already gated upstream). + @ObservationIgnored private var bypassCloseConfirmationSurfaceIDs: Set = [] @ObservationIgnored private var surfaceGenerationByTab: [TerminalTabID: Int] = [:] @ObservationIgnored private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] /// Per-tab projection cache. `WorktreeTerminalState` recomputes from `trees` @@ -82,6 +95,7 @@ final class WorktreeTerminalState { @ObservationIgnored private var lastTabProgressDisplays: [TerminalTabID: TerminalTabProgressDisplay?] = [:] var socketPath: String? private(set) var shouldHideTabBar = false + private(set) var pendingCloseConfirmation: PendingCloseConfirmation? // Every mutation schedules a coalesced row-projection emit so the TCA // mirror of running scripts reconciles from this single source of truth (#573). private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] { @@ -94,7 +108,7 @@ final class WorktreeTerminalState { private var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] private var pendingSetupScript: Bool - /// Sticky after first attempt so a reselect after `closeAllTabs` doesn't auto-recreate. + /// Sticky after first attempt so a reselect after closing every tab doesn't auto-recreate. /// Intentionally never reset; resetting would re-arm the bug. @ObservationIgnored private(set) var hasAttemptedInitialTab = false @ObservationIgnored var pendingLayoutSnapshot: TerminalLayoutSnapshot? @@ -224,10 +238,14 @@ final class WorktreeTerminalState { runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false, - splitPreserveZoomOnNavigation: (() -> Bool)? = nil + splitPreserveZoomOnNavigation: (() -> Bool)? = nil, + surfaceNeedsCloseConfirmation: ((GhosttySurfaceView) -> Bool)? = nil, + surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? = nil ) { self.runtime = runtime self.splitPreserveZoomOnNavigation = splitPreserveZoomOnNavigation ?? { runtime.splitPreserveZoomOnNavigation() } + self.surfaceNeedsCloseConfirmation = surfaceNeedsCloseConfirmation ?? { $0.needsCloseConfirmation } + self.surfaceBindingActionPerformer = surfaceBindingActionPerformer ?? { $0.performBindingAction($1) } self.worktree = worktree self.pendingSetupScript = runSetupScript self.tabManager = TerminalTabManager() @@ -751,8 +769,7 @@ final class WorktreeTerminalState { @discardableResult func closeFocusedTab() -> Bool { guard let tabId = tabManager.selectedTabId else { return false } - closeTab(tabId) - return true + return requestCloseTab(tabId) } @discardableResult @@ -774,11 +791,15 @@ final class WorktreeTerminalState { "closeSurface: surface \(surfaceID) not found. Known: \(surfaces.keys.map(\.uuidString))") return false } - requestExplicitSurfaceClose(surface) + // Programmatic destroys (deeplink/CLI) resolve confirmation upstream, so skip the alert here. + requestExplicitSurfaceClose(surface, confirm: false) return true } - private func requestExplicitSurfaceClose(_ surface: GhosttySurfaceView) { + private func requestExplicitSurfaceClose(_ surface: GhosttySurfaceView, confirm: Bool = true) { + if !confirm { + bypassCloseConfirmationSurfaceIDs.insert(surface.id) + } performBindingAction("close_surface", on: surface) } @@ -812,7 +833,7 @@ final class WorktreeTerminalState { if action == "close_surface" { pendingExplicitSurfaceCloseIDs.insert(surface.id) } - surface.performBindingAction(action) + surfaceBindingActionPerformer(surface, action) } @discardableResult @@ -827,7 +848,104 @@ final class WorktreeTerminalState { return true } + @discardableResult + func requestCloseTab(_ tabId: TerminalTabID) -> Bool { + requestCloseTabs([tabId]) + } + + @discardableResult + func requestCloseOtherTabs(keeping tabId: TerminalTabID) -> Bool { + requestCloseTabs(tabManager.tabs.map(\.id).filter { $0 != tabId }) + } + + @discardableResult + func requestCloseTabsToRight(of tabId: TerminalTabID) -> Bool { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return false } + return requestCloseTabs(Array(tabManager.tabs.dropFirst(index + 1).map(\.id))) + } + + @discardableResult + func requestCloseAllTabs() -> Bool { + requestCloseTabs(tabManager.tabs.map(\.id)) + } + + func confirmPendingClose() { + guard let pending = pendingCloseConfirmation else { return } + confirmPendingClose(pending) + } + + // Takes the target explicitly so the alert confirms against the captured + // payload, never a published value a concurrent dismissal may have cleared. + func confirmPendingClose(_ pending: PendingCloseConfirmation) { + pendingCloseConfirmation = nil + switch pending { + case .surface(let surfaceID): + guard let surface = surfaces[surfaceID] else { + terminalStateLogger.debug("confirmPendingClose: surface \(surfaceID) already gone.") + return + } + completeCloseRequest(for: surface) + case .tabs(let tabIDs): + for tabId in tabIDs { + closeTab(tabId) + } + } + } + + func cancelPendingClose() { + guard let pending = pendingCloseConfirmation else { return } + cancelPendingClose(pending) + } + + // Takes the target explicitly so a dismissal that clears the published value + // first can't strip the surface's explicit-close flag out from under cancel. + func cancelPendingClose(_ pending: PendingCloseConfirmation) { + if case .surface(let surfaceID) = pending { + pendingExplicitSurfaceCloseIDs.remove(surfaceID) + } + pendingCloseConfirmation = nil + } + + // The alert binding writes nil back on dismissal; the buttons own the real + // transitions, so this only clears without any cancel side effects. + func dismissPendingCloseConfirmation() { + pendingCloseConfirmation = nil + } + + private func requestCloseTabs(_ requestedTabIDs: [TerminalTabID]) -> Bool { + let existingTabIDs = requestedTabIDs.filter { requested in + tabManager.tabs.contains(where: { $0.id == requested }) + } + guard !existingTabIDs.isEmpty else { return false } + guard pendingCloseConfirmation == nil else { return true } + + @Shared(.settingsFile) var settingsFile + let needsConfirmation = + settingsFile.global.confirmCloseSurface + && existingTabIDs.contains(where: tabNeedsCloseConfirmation) + if needsConfirmation { + pendingCloseConfirmation = .tabs(existingTabIDs) + } else { + for tabId in existingTabIDs { + closeTab(tabId) + } + } + return true + } + + private func tabNeedsCloseConfirmation(_ tabId: TerminalTabID) -> Bool { + guard let tree = trees[tabId] else { return false } + return tree.leaves().contains(where: surfaceNeedsCloseConfirmation) + } + + private func removeFromPendingClose(tabId: TerminalTabID) { + guard case .tabs(let tabIDs)? = pendingCloseConfirmation else { return } + let remaining = tabIDs.filter { $0 != tabId } + pendingCloseConfirmation = remaining.isEmpty ? nil : .tabs(remaining) + } + func closeTab(_ tabId: TerminalTabID) { + removeFromPendingClose(tabId: tabId) let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) cleanupBlockingScriptLaunchDirectory(for: tabId) // Clear lingering tab tracking for completed or non-blocking tabs. @@ -860,28 +978,6 @@ final class WorktreeTerminalState { return true } - func closeOtherTabs(keeping tabId: TerminalTabID) { - let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } - for id in ids { - closeTab(id) - } - } - - func closeTabsToRight(of tabId: TerminalTabID) { - guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let ids = tabManager.tabs.dropFirst(index + 1).map(\.id) - for id in ids { - closeTab(id) - } - } - - func closeAllTabs() { - let ids = tabManager.tabs.map(\.id) - for id in ids { - closeTab(id) - } - } - func splitTree( for tabId: TerminalTabID, inheritingFromSurfaceId: UUID? = nil, @@ -1058,6 +1154,7 @@ final class WorktreeTerminalState { } func closeAllSurfaces() { + cancelPendingClose() let closingSurfaces = Array(surfaces.values) let closingSurfaceIDs = closingSurfaces.map(\.id) for surface in closingSurfaces { @@ -1694,10 +1791,18 @@ final class WorktreeTerminalState { guard self.isLiveSurface(view) else { return false } return self.createTab(inheritingFromSurfaceId: view.id) != nil } - view.bridge.onCloseTab = { [weak self, weak view] _ in + view.bridge.onCloseTab = { [weak self, weak view] mode in guard let self, let view, self.isLiveSurface(view) else { return false } - self.closeTab(tabId) - return true + // Ghostty's palette/keybind close-tab carries the scope; honor each so + // "close others" / "close to the right" route through confirmation too. + switch mode { + case GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER: + return self.requestCloseOtherTabs(keeping: tabId) + case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: + return self.requestCloseTabsToRight(of: tabId) + default: + return self.requestCloseTab(tabId) + } } view.bridge.onGotoTab = { [weak self, weak view] target in guard let self, let view, self.isLiveSurface(view) else { return false } @@ -1737,9 +1842,9 @@ final class WorktreeTerminalState { guard self.isLiveSurface(view) else { return } self.handleContextSignal(surfaceID: view.id, id: id, metadata: metadata) } - view.bridge.onCloseRequest = { [weak self, weak view] _ in + view.bridge.onCloseRequest = { [weak self, weak view] needsConfirmation in guard let self, let view else { return } - self.handleCloseRequest(for: view) + self.handleCloseRequest(for: view, needsConfirmation: needsConfirmation) } view.onFocusChange = { [weak self, weak view] focused in guard let self, let view, focused else { return } @@ -2231,11 +2336,17 @@ final class WorktreeTerminalState { /// Also cancels any held agent OSC 9 and forgets the last-custom-notification /// instant so a future surface ID can't reuse stale dedupe state. private func discardSurfaceBookkeeping(for surfaceID: UUID) { + if case .surface(let pendingSurfaceID)? = pendingCloseConfirmation, + pendingSurfaceID == surfaceID + { + pendingCloseConfirmation = nil + } pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() lastCustomNotificationAt.removeValue(forKey: surfaceID) surfaces.removeValue(forKey: surfaceID) surfaceLaunchMetadata.removeValue(forKey: surfaceID) pendingExplicitSurfaceCloseIDs.remove(surfaceID) + bypassCloseConfirmationSurfaceIDs.remove(surfaceID) surfaceStates.removeValue(forKey: surfaceID) } @@ -2555,7 +2666,33 @@ final class WorktreeTerminalState { } } - private func handleCloseRequest(for view: GhosttySurfaceView) { + private func handleCloseRequest(for view: GhosttySurfaceView, needsConfirmation: Bool) { + guard surfaces[view.id] === view else { return } + if bypassCloseConfirmationSurfaceIDs.remove(view.id) != nil { + terminalStateLogger.debug("handleCloseRequest: bypassing confirmation for \(view.id).") + completeCloseRequest(for: view) + return + } + let isExplicitClose = pendingExplicitSurfaceCloseIDs.contains(view.id) + if isExplicitClose, pendingCloseConfirmation != nil { + if pendingCloseConfirmation != .surface(view.id) { + pendingExplicitSurfaceCloseIDs.remove(view.id) + } + return + } + + @Shared(.settingsFile) var settingsFile + if needsConfirmation, + isExplicitClose, + settingsFile.global.confirmCloseSurface + { + pendingCloseConfirmation = .surface(view.id) + return + } + completeCloseRequest(for: view) + } + + private func completeCloseRequest(for view: GhosttySurfaceView) { guard surfaces[view.id] === view else { return } let isExplicitClose = pendingExplicitSurfaceCloseIDs.remove(view.id) != nil if shouldHandleAsUnexpectedZmxClose( @@ -2696,6 +2833,7 @@ final class WorktreeTerminalState { killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) } if newTree.isEmpty { + removeFromPendingClose(tabId: tabId) trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) cleanupBlockingScriptLaunchDirectory(for: tabId) diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 7fa4d82b1..fa798cf47 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -40,16 +40,16 @@ struct WorktreeTerminalTabsView: View { }, canSplit: state.tabManager.selectedTabId.flatMap { state.activeSurfaceID(for: $0) } != nil, closeTab: { tabId in - state.closeTab(tabId) + _ = state.requestCloseTab(tabId) }, closeOthers: { tabId in - state.closeOtherTabs(keeping: tabId) + _ = state.requestCloseOtherTabs(keeping: tabId) }, closeToRight: { tabId in - state.closeTabsToRight(of: tabId) + _ = state.requestCloseTabsToRight(of: tabId) }, closeAll: { - state.closeAllTabs() + _ = state.requestCloseAllTabs() }, dismissSplitZoom: { tabId in state.dismissSplitZoom(for: tabId) @@ -75,6 +75,22 @@ struct WorktreeTerminalTabsView: View { } } .animation(.easeInOut(duration: 0.2), value: state.shouldHideTabBar) + .alert( + item: Binding( + get: { state.pendingCloseConfirmation }, + set: { if $0 == nil { state.dismissPendingCloseConfirmation() } } + ), + title: { _ in Text(WorktreeTerminalState.PendingCloseConfirmation.title) }, + actions: { pending in + Button("Cancel", role: .cancel) { + state.cancelPendingClose(pending) + } + Button(WorktreeTerminalState.PendingCloseConfirmation.actionTitle, role: .destructive) { + state.confirmPendingClose(pending) + } + }, + message: { _ in Text(WorktreeTerminalState.PendingCloseConfirmation.message) } + ) .background( WindowFocusObserverView { activity in windowActivity = activity diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift index eea35dfc5..b0c9766bc 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -566,8 +566,8 @@ final class GhosttyRuntime { return min(max(value, 0), 1) } - /// Applies Supacode-specific config (padding values) that takes precedence - /// over user settings. + /// Applies Supacode-specific config that takes precedence over user + /// settings. /// /// No `background-opacity` override: surfaces render translucent at the /// theme's opacity and keep their own OSC 11 color. The window tint behind @@ -578,9 +578,15 @@ final class GhosttyRuntime { /// override): surfaces run the real shell with zmx injected as a Ghostty /// `command-wrapper`, so Ghostty resolves and integrates the shell exactly as /// it would without zmx, honoring the user's `command` / `shell-integration`. + /// + /// Supacode owns close-confirmation policy and UI. Keeping Ghostty's + /// predicate enabled makes its callback report prompt safety independently + /// of the user's Ghostty setting; `GlobalSettings.confirmCloseSurface` + /// decides whether Supacode presents the alert. internal static let bundledOverridesString = """ window-padding-x = 14 window-padding-y = 12,0 + confirm-close-surface = true """ /// Reports Supacode in `TERM_PROGRAM` so programs detect the real host diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 3f549cfc4..3c059fcab 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -279,6 +279,11 @@ final class GhosttySurfaceView: NSView, Identifiable { } } + var needsCloseConfirmation: Bool { + guard let surface else { return false } + return ghostty_surface_needs_confirm_quit(surface) + } + func closeSurface() { clearNotificationObservers() if let surface { diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index f492e3721..b465070dc 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -595,7 +595,7 @@ struct AgentBusyStateTests { $0.continuousClock = ImmediateClock() } operation: { withRetentionLimit(.oneHundred) { - let fixture = makeStateWithSurface() + let fixture = makeStateWithSurface(surfaceBindingActionPerformer: { _, _ in }) // Split so the tab keeps a sibling pane; the split focuses the sibling, // leaving the original pane unfocused so its notification lands unread. let sibling = UUID() @@ -720,8 +720,13 @@ struct AgentBusyStateTests { Self.makeHookEvent(name, agent: agent, surfaceID: surfaceID, pid: pid) } - private func makeStateWithSurface(worktree: Worktree? = nil) -> SurfaceFixture { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + private func makeStateWithSurface( + worktree: Worktree? = nil, + surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? = nil + ) -> SurfaceFixture { + let (manager, presence) = WorktreeTerminalManager.withPresenceHarness( + surfaceBindingActionPerformer: surfaceBindingActionPerformer + ) let resolvedWorktree = worktree ?? makeWorktree() let state = manager.state(for: resolvedWorktree) { false } diff --git a/supacodeTests/AgentPresence+TestHelpers.swift b/supacodeTests/AgentPresence+TestHelpers.swift index 66c732ace..2f1b66660 100644 --- a/supacodeTests/AgentPresence+TestHelpers.swift +++ b/supacodeTests/AgentPresence+TestHelpers.swift @@ -114,9 +114,15 @@ extension WorktreeTerminalManager { runtime: GhosttyRuntime = GhosttyRuntime(), socketServer: AgentHookSocketServer? = nil, clock: some Clock = ContinuousClock(), + surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? = nil ) -> (manager: WorktreeTerminalManager, presence: PresenceTestHarness) { let harness = PresenceTestHarness() - let manager = WorktreeTerminalManager(runtime: runtime, socketServer: socketServer, clock: clock) + let manager = WorktreeTerminalManager( + runtime: runtime, + socketServer: socketServer, + clock: clock, + surfaceBindingActionPerformer: surfaceBindingActionPerformer + ) harness.attach(to: manager) return (manager, harness) } diff --git a/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift b/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift index a703773ac..b0dd50915 100644 --- a/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift +++ b/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift @@ -48,6 +48,10 @@ struct GhosttyRuntimeBundledOverridesTests { #expect(!GhosttyRuntime.bundledOverridesString.contains("shell-integration")) } + @Test func bundledOverridesKeepSurfaceCloseDetectionEnabled() { + #expect(GhosttyRuntime.bundledOverridesString.contains("confirm-close-surface = true")) + } + /// Each line in the heredoc is parsed as a Ghostty `key = value` directive /// by `ghostty_config_load_file`. Catches accidental free-form text edits. @Test func bundledOverridesAreKeyValueDirectives() { diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 98a2d6cdf..10dabba99 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -30,6 +30,7 @@ struct SettingsFeatureTests { promptForWorktreeCreation: true, terminalThemeSyncEnabled: false, automatedActionPolicy: .always, + confirmCloseSurface: false, ) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = loaded } @@ -60,6 +61,7 @@ struct SettingsFeatureTests { $0.fetchOriginBeforeWorktreeCreation = true $0.terminalThemeSyncEnabled = false $0.automatedActionPolicy = .always + $0.confirmCloseSurface = false } await store.skipReceivedActions() receiveStartupHookChecks(from: store) @@ -113,6 +115,23 @@ struct SettingsFeatureTests { expectNoDifference(settingsFile.global, expectedSettings) } + @Test(.dependencies) func confirmCloseSurfacePersistsChanges() async { + var initialSettings = GlobalSettings.default + initialSettings.confirmCloseSurface = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.confirmCloseSurface, false))) { + $0.confirmCloseSurface = false + } + await store.receive(\.delegate.settingsChanged) + #expect(!settingsFile.global.confirmCloseSurface) + } + @Test(.dependencies) func setSystemNotificationsEnabledPersistsChanges() async { var initialSettings = GlobalSettings.default initialSettings.systemNotificationsEnabled = false diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 867be7a58..457f21da5 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -387,6 +387,48 @@ struct SettingsFilePersistenceTests { #expect(reloaded.global.terminalThemeSyncEnabled == true) } + @Test(.dependencies) func decodesMissingConfirmCloseSurfaceAsTrue() throws { + let legacy = LegacySettingsFile( + global: LegacyGlobalSettings( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.confirmCloseSurface) + } + + @Test(.dependencies) func roundTripsExplicitConfirmCloseSurfaceDisabled() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { $0.global.confirmCloseSurface = false } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var reloaded: SettingsFile + return reloaded + } + + #expect(!reloaded.global.confirmCloseSurface) + } + @Test(.dependencies) func decodesMissingRemoteSessionPersistenceEnabledAsTrue() throws { let legacy = LegacySettingsFile( global: LegacyGlobalSettings( diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index f2ffc5bd4..3419932b4 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1,9 +1,11 @@ import AppKit import Clocks import Dependencies +import DependenciesTestSupport import Foundation import GhosttyKit import IdentifiedCollections +import Sharing import SupacodeSettingsShared import Testing @@ -75,7 +77,9 @@ struct WorktreeTerminalManagerTests { let state = manager.state(for: worktree) state.ensureInitialTab(focusing: false) - state.closeAllTabs() + for tab in state.tabManager.tabs { + state.closeTab(tab.id) + } state.ensureInitialTab(focusing: false) @@ -368,7 +372,9 @@ struct WorktreeTerminalManagerTests { #expect(state.currentProjection().runningScripts.isEmpty) continuation.resume() } - state.closeAllTabs() + for tab in state.tabManager.tabs { + state.closeTab(tab.id) + } } } @@ -1126,6 +1132,450 @@ struct WorktreeTerminalManagerTests { #expect(state.surfaceStates[surfaceID] == nil) } + @Test(.dependencies) func explicitSurfaceCloseConfirmsWhenProcessNeedsConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + + #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) + surface.bridge.closeSurface(processAlive: true) + let pending = state.pendingCloseConfirmation + #expect(pending == .surface(surface.id)) + #expect(WorktreeTerminalState.PendingCloseConfirmation.title == "Close Terminal?") + #expect(WorktreeTerminalState.PendingCloseConfirmation.actionTitle == "Close Terminal") + #expect(state.hasTab(tabId)) + + state.cancelPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.hasTab(tabId)) + + #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) + surface.bridge.closeSurface(processAlive: true) + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tabId)) + } + + @Test(.dependencies) func confirmedSplitSurfaceCloseRemovesOnlyTargetPane() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + #expect(state.performSplitAction(.newSplit(direction: .right), for: initialSurface.id)) + let leaves = state.splitTree(for: tabId).leaves() + guard leaves.count == 2 else { + Issue.record("Expected a split tab") + return + } + let target = leaves[1] + + #expect(state.performBindingAction("close_surface", onSurfaceID: target.id)) + target.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == .surface(target.id)) + + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.hasTab(tabId)) + #expect(state.splitTree(for: tabId).leaves().map(\.id) == [initialSurface.id]) + } + + @Test(.dependencies) func secondSurfaceCloseDoesNotRetargetPendingConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + #expect(state.performSplitAction(.newSplit(direction: .right), for: initialSurface.id)) + let leaves = state.splitTree(for: tabId).leaves() + guard leaves.count == 2 else { + Issue.record("Expected a split tab") + return + } + let secondSurface = leaves[1] + + #expect(state.performBindingAction("close_surface", onSurfaceID: secondSurface.id)) + secondSurface.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == .surface(secondSurface.id)) + + #expect(state.performBindingAction("close_surface", onSurfaceID: initialSurface.id)) + initialSurface.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == .surface(secondSurface.id)) + + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.hasTab(tabId)) + #expect(state.splitTree(for: tabId).leaves().map(\.id) == [initialSurface.id]) + + #expect(state.performBindingAction("close_surface", onSurfaceID: initialSurface.id)) + initialSurface.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == .surface(initialSurface.id)) + state.cancelPendingClose() + } + + @Test(.dependencies) func explicitIdleSurfaceCloseSkipsConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + + #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) + surface.bridge.closeSurface(processAlive: false) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tabId)) + } + + @Test(.dependencies) func disabledSettingClosesRunningSurfaceWithoutConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = false } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + + #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) + surface.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tabId)) + } + @Test(.dependencies) func requestCloseTabConfirmsWhenAnySplitSurfaceHasRunningProcess() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + var runningSurfaceIDs: Set = [] + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { runningSurfaceIDs.contains($0.id) } + ) + guard let tabId = state.createTab(focusing: true), + let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + #expect(state.performSplitAction(.newSplit(direction: .right), for: initialSurface.id)) + let leaves = state.splitTree(for: tabId).leaves() + guard leaves.count == 2 else { + Issue.record("Expected a split tab") + return + } + runningSurfaceIDs.insert(leaves[1].id) + + #expect(state.requestCloseTab(tabId)) + #expect(state.pendingCloseConfirmation == .tabs([tabId])) + #expect(state.tabManager.tabs.contains(where: { $0.id == tabId })) + + state.cancelPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.tabManager.tabs.contains(where: { $0.id == tabId })) + + #expect(state.requestCloseTab(tabId)) + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.tabManager.tabs.contains(where: { $0.id == tabId })) + } + + @Test(.dependencies) func requestCloseTabClosesIdleTabWithoutConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { _ in false } + ) + guard let tabId = state.createTab(focusing: true) else { + Issue.record("Expected a tab") + return + } + + #expect(state.requestCloseTab(tabId)) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.tabManager.tabs.contains(where: { $0.id == tabId })) + } + + @Test(.dependencies) func disabledSettingClosesRunningTabWithoutConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = false } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { _ in true } + ) + guard let tabId = state.createTab(focusing: true) else { + Issue.record("Expected a tab") + return + } + + #expect(state.requestCloseTab(tabId)) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.tabManager.tabs.contains(where: { $0.id == tabId })) + } + + @Test(.dependencies) func programmaticSurfaceDestroyBypassesConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + + #expect(state.closeSurface(id: surface.id)) + surface.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tabId)) + } + + @Test(.dependencies) func confirmingCapturedTargetClosesEvenAfterDismissalClearedState() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + + #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) + surface.bridge.closeSurface(processAlive: true) + guard let pending = state.pendingCloseConfirmation else { + Issue.record("Expected a pending confirmation") + return + } + + // Simulate SwiftUI writing the dismissal back through the alert binding + // before the confirm button's action runs on the same tap. + state.dismissPendingCloseConfirmation() + #expect(state.pendingCloseConfirmation == nil) + state.confirmPendingClose(pending) + #expect(!state.hasTab(tabId)) + } + + @Test(.dependencies) func requestCloseOtherTabsConfirmsThenClosesExactlyOthers() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + var runningSurfaceIDs: Set = [] + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { runningSurfaceIDs.contains($0.id) } + ) + guard let first = state.createTab(focusing: true), + let second = state.createTab(focusing: true), + let third = state.createTab(focusing: true), + let secondSurface = state.splitTree(for: second).root?.leftmostLeaf() + else { + Issue.record("Expected three tabs") + return + } + runningSurfaceIDs.insert(secondSurface.id) + + #expect(state.requestCloseOtherTabs(keeping: first)) + #expect(state.pendingCloseConfirmation == .tabs([second, third])) + + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.tabManager.tabs.map(\.id) == [first]) + } + + @Test(.dependencies) func requestCloseTabsToRightTargetsOnlyRightwardTabs() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + var runningSurfaceIDs: Set = [] + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { runningSurfaceIDs.contains($0.id) } + ) + guard let first = state.createTab(focusing: true), + let second = state.createTab(focusing: true), + let third = state.createTab(focusing: true), + let thirdSurface = state.splitTree(for: third).root?.leftmostLeaf() + else { + Issue.record("Expected three tabs") + return + } + runningSurfaceIDs.insert(thirdSurface.id) + + #expect(state.requestCloseTabsToRight(of: first)) + #expect(state.pendingCloseConfirmation == .tabs([second, third])) + + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.tabManager.tabs.map(\.id) == [first]) + } + + @Test(.dependencies) func requestCloseAllTabsConfirmsThenClosesEveryTab() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + var runningSurfaceIDs: Set = [] + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { runningSurfaceIDs.contains($0.id) } + ) + guard let first = state.createTab(focusing: true), + let second = state.createTab(focusing: true), + let third = state.createTab(focusing: true), + let secondSurface = state.splitTree(for: second).root?.leftmostLeaf() + else { + Issue.record("Expected three tabs") + return + } + runningSurfaceIDs.insert(secondSurface.id) + + #expect(state.requestCloseAllTabs()) + #expect(state.pendingCloseConfirmation == .tabs([first, second, third])) + + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(state.tabManager.tabs.isEmpty) + } + + @Test(.dependencies) func ghosttyCloseTabModesRouteThroughConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + var runningSurfaceIDs: Set = [] + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { runningSurfaceIDs.contains($0.id) } + ) + guard let first = state.createTab(focusing: true), + let second = state.createTab(focusing: true), + let third = state.createTab(focusing: true), + let firstSurface = state.splitTree(for: first).root?.leftmostLeaf(), + let secondSurface = state.splitTree(for: second).root?.leftmostLeaf() + else { + Issue.record("Expected three tabs") + return + } + runningSurfaceIDs.insert(secondSurface.id) + + // "Close Other Tabs" from the first tab confirms because a sibling is busy. + #expect(firstSurface.bridge.onCloseTab?(GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER) == true) + #expect(state.pendingCloseConfirmation == .tabs([second, third])) + state.cancelPendingClose() + + // "Close Tabs to the Right" of the first tab targets the same siblings. + #expect(firstSurface.bridge.onCloseTab?(GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT) == true) + #expect(state.pendingCloseConfirmation == .tabs([second, third])) + state.cancelPendingClose() + + // "Close Tab" scopes to the invoking (idle) tab and closes immediately. + #expect(firstSurface.bridge.onCloseTab?(GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS) == true) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(first)) + } + + @Test(.dependencies) func closingTabIndependentlyNarrowsPendingTabPayload() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + var runningSurfaceIDs: Set = [] + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceNeedsCloseConfirmation: { runningSurfaceIDs.contains($0.id) } + ) + guard let first = state.createTab(focusing: true), + let second = state.createTab(focusing: true), + let third = state.createTab(focusing: true), + let secondSurface = state.splitTree(for: second).root?.leftmostLeaf() + else { + Issue.record("Expected three tabs") + return + } + runningSurfaceIDs.insert(secondSurface.id) + + #expect(state.requestCloseAllTabs()) + #expect(state.pendingCloseConfirmation == .tabs([first, second, third])) + + state.closeTab(first) + #expect(state.pendingCloseConfirmation == .tabs([second, third])) + #expect(!state.hasTab(first)) + + state.closeTab(second) + state.closeTab(third) + #expect(state.pendingCloseConfirmation == nil) + } + + @Test(.dependencies) func tearingDownPendingSurfaceTabClearsConfirmation() { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + surfaceBindingActionPerformer: { _, _ in } + ) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + + #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) + surface.bridge.closeSurface(processAlive: true) + #expect(state.pendingCloseConfirmation == .surface(surface.id)) + + state.closeTab(tabId) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tabId)) + } + @Test func closeAllSurfacesClearsPerSurfaceBookkeeping() { withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234) @@ -1292,7 +1742,11 @@ struct WorktreeTerminalManagerTests { // host-side session dies alongside the local one. let probe = ZmxTestProbe(listing: []) let worktree = makeRemoteWorktree() - let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let manager = makeZmxBackedManager( + probe: probe, + worktree: worktree, + surfaceBindingActionPerformer: { _, _ in } + ) let state = manager.state(for: worktree) guard let tabID = state.createTab(focusing: true), let surface = state.splitTree(for: tabID).root?.leftmostLeaf() @@ -1310,13 +1764,19 @@ struct WorktreeTerminalManagerTests { #expect(remoteKills.contains(.init(authority: "devbox", sessionID: sessionID))) } - @Test func closedTabStaleRenderDoesNotResurrectSurface() { + @Test(.dependencies) func closedTabStaleRenderDoesNotResurrectSurface() { // A SwiftUI pane can re-render its tab during the tab-close transition; // the lazy splitTree(for:) create must not mint a replacement surface for // the dead tab, or an invisible surface leaks a local+host session pair. + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } let probe = ZmxTestProbe(listing: []) let worktree = makeRemoteWorktree() - let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let manager = makeZmxBackedManager( + probe: probe, + worktree: worktree, + surfaceBindingActionPerformer: { _, _ in } + ) let state = manager.state(for: worktree) guard let tabID = state.createTab(focusing: true), let surface = state.splitTree(for: tabID).root?.leftmostLeaf() @@ -1327,6 +1787,7 @@ struct WorktreeTerminalManagerTests { #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) surface.bridge.closeSurface(processAlive: true) + state.confirmPendingClose() #expect(state.hasTab(tabID) == false) #expect(state.splitTree(for: tabID).isEmpty) @@ -1569,6 +2030,47 @@ struct WorktreeTerminalManagerTests { #expect(await probe.killedSessions() == []) } + @Test(.dependencies) func canceledSurfaceCloseClearsExplicitFlagSoUnexpectedExitReattaches() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = true } + let probe = ZmxTestProbe(listing: []) + let manager = makeZmxBackedManager( + probe: probe, + surfaceBindingActionPerformer: { _, _ in } + ) + let state = manager.state(for: makeWorktree()) + guard let tabId = state.createTab(focusing: true), + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + let surfaceID = surface.id + await probe.setListing([.init(name: session(for: surfaceID), clients: 0)]) + + // Park a surface-close confirmation, then cancel it the way the alert does: + // the item binding nils the published value before the Cancel action runs. + #expect(state.performBindingAction("close_surface", onSurfaceID: surfaceID)) + surface.bridge.closeSurface(processAlive: true) + guard let pending = state.pendingCloseConfirmation else { + Issue.record("Expected a pending confirmation") + return + } + state.dismissPendingCloseConfirmation() + state.cancelPendingClose(pending) + + // The explicit-close flag must have been cleared, so a later unexpected exit + // reattaches the live session instead of tearing it down. + surface.bridge.closeSurface(processAlive: false) + await probe.waitForListCalls(atLeast: 1) + await waitUntil("zmx surface replacement") { + guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf() else { return false } + return replacement.id == surfaceID && replacement !== surface + } + #expect(state.tabManager.tabs.contains(where: { $0.id == tabId })) + #expect(await probe.killedSessions() == []) + } + @Test func unexpectedDetachedZmxSurfaceWithLiveSessionReattachesAndKeepsTab() async { let probe = ZmxTestProbe(listing: []) let manager = makeZmxBackedManager(probe: probe) @@ -1780,7 +2282,10 @@ struct WorktreeTerminalManagerTests { @Test func explicitExitedZmxSurfaceCloseDoesNotRecoverLiveSession() async { let probe = ZmxTestProbe(listing: []) - let manager = makeZmxBackedManager(probe: probe) + let manager = makeZmxBackedManager( + probe: probe, + surfaceBindingActionPerformer: { _, _ in } + ) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), let surface = state.splitTree(for: tabId).root?.leftmostLeaf() @@ -1808,7 +2313,10 @@ struct WorktreeTerminalManagerTests { @Test func closeSurfaceBindingActionDoesNotRecoverLiveSession() async { let probe = ZmxTestProbe(listing: []) - let manager = makeZmxBackedManager(probe: probe) + let manager = makeZmxBackedManager( + probe: probe, + surfaceBindingActionPerformer: { _, _ in } + ) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), let surface = state.splitTree(for: tabId).root?.leftmostLeaf() @@ -2888,7 +3396,11 @@ struct WorktreeTerminalManagerTests { /// `worktree` seeds the pre-created state INSIDE the dependency scope, so /// its `@Dependency(\.zmxClient)` captures the probe-backed client. Tests /// must fetch the state with the same worktree id. - private func makeZmxBackedManager(probe: ZmxTestProbe, worktree: Worktree? = nil) -> WorktreeTerminalManager { + private func makeZmxBackedManager( + probe: ZmxTestProbe, + worktree: Worktree? = nil, + surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? = nil + ) -> WorktreeTerminalManager { let zmxURL = makeFakeZmxBinary() return withDependencies { @@ -2900,7 +3412,10 @@ struct WorktreeTerminalManagerTests { listSessionsWithClients: { await probe.listSessionsWithClients() }, ) } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeTerminalManager( + runtime: GhosttyRuntime(), + surfaceBindingActionPerformer: surfaceBindingActionPerformer + ) _ = manager.state(for: worktree ?? makeWorktree()) return manager }