import Foundation struct TerminalCloseProtectionCandidate: Equatable { let hasAgent: Bool let agentDisplayState: AgentDisplayState? let commandRunningDuration: TimeInterval? var editingAge: TimeInterval? var hasMarkedText = false } enum TerminalCloseProtectionReason: Equatable, Hashable { case recentInput case agentActive case longRunningCommand } struct TerminalCloseConfirmationDecision: Equatable { let protectedPaneCount: Int let reasons: Set var requiresConfirmation: Bool { protectedPaneCount > 0 } } enum TerminalCloseConfirmationPolicy { static let recentEditingThreshold: TimeInterval = 10 static let longRunningCommandThreshold: TimeInterval = 10 static func decision( for candidates: [TerminalCloseProtectionCandidate], threshold: TimeInterval = Self.longRunningCommandThreshold ) -> TerminalCloseConfirmationDecision { var protectedPaneCount = 0 var reasons: Set = [] for candidate in candidates { var paneReasons: Set = [] if candidate.hasMarkedText || candidate.editingAge.map({ $0 <= recentEditingThreshold }) == true { paneReasons.insert(.recentInput) } if let reason = protectionReason(for: candidate, threshold: threshold) { paneReasons.insert(reason) } guard !paneReasons.isEmpty else { continue } protectedPaneCount += 1 reasons.formUnion(paneReasons) } return TerminalCloseConfirmationDecision( protectedPaneCount: protectedPaneCount, reasons: reasons ) } /// Alert body for a close prompt. Always names the worktree: the prompt can /// be triggered from the sidebar against a worktree whose tabs are not /// visible, so the target's identity must be part of the confirmation. static func informativeMessage( for decision: TerminalCloseConfirmationDecision, worktreeName: String ) -> String { let paneText = decision.protectedPaneCount == 1 ? String(localized: "pane") : String(localized: "panes") let reasonText: String if decision.reasons.contains(.recentInput) { var activities = [String(localized: "recent input")] if decision.reasons.contains(.agentActive) { activities.append(String(localized: "active agent work or an unseen agent result")) } if decision.reasons.contains(.longRunningCommand) { activities.append(String(localized: "a command that has been running for at least 10 seconds")) } let activitiesList = activities.joined(separator: ", ") let template = String(localized: "This will close %lld %@ in “%@” with %@. Closing may lose unsubmitted input.") return String(format: template, decision.protectedPaneCount, paneText, worktreeName, activitiesList) } else if decision.reasons == Set([.agentActive]) { reasonText = String(localized: "active agent work or an unseen agent result") } else if decision.reasons == Set([.longRunningCommand]) { reasonText = String(localized: "a command that has been running for at least 10 seconds") } else { reasonText = String(localized: "active agent work, unseen agent results, or long-running commands") } let template = String(localized: "This will close %lld %@ in “%@” with %@.") return String(format: template, decision.protectedPaneCount, paneText, worktreeName, reasonText) } private static func protectionReason( for candidate: TerminalCloseProtectionCandidate, threshold: TimeInterval ) -> TerminalCloseProtectionReason? { if candidate.hasAgent { switch candidate.agentDisplayState { case .working, .blocked, .done: return .agentActive case .idle, .none: return nil } } guard let duration = candidate.commandRunningDuration, duration >= threshold else { return nil } return .longRunningCommand } }